mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 03:23:20 +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
@@ -85,6 +85,37 @@ Ordinary file saving by Obsidian remains independent of database saving. Pending
|
||||
and processing indicators must include work in progress and unresolved failures,
|
||||
so an empty timer map cannot imply that local processing is complete.
|
||||
|
||||
### Optional file ownership and completion
|
||||
|
||||
Hidden File Sync and Customisation Sync register the new
|
||||
`processOptionalFileEventWithResult` contract together. `firstHandled` tries
|
||||
providers in order until one returns `completed` or `veto`; only
|
||||
`not-applicable` advances to the next provider. Exceptions propagate unchanged.
|
||||
`HandlerOutcome<TValue, TVetoReason>` gives completion data and refusal reasons
|
||||
independent types. These saves use `void` completion data and string reasons.
|
||||
|
||||
A provider returns `completed` after its database operation succeeds or the
|
||||
current state is already represented. Failed writes and deletions throw, and
|
||||
the manager retains the event. Hidden File Sync acknowledges its processed
|
||||
fingerprint after successful processing. Customisation Sync waits for the real
|
||||
save after its existing 100ms quiet period. All notifications coalesced into a
|
||||
V1 group receive that save's result; V2 files retain separate save groups.
|
||||
|
||||
A finite flush resolves each captured event's `whenFlushRequested` signal. This
|
||||
releases its customisation delay and freezes that group; later groups do not
|
||||
extend its completion. During start-up replay or suspension, providers use
|
||||
physical database readiness rather than waiting for application readiness or
|
||||
resume. Hidden File Sync loads its existing per-database caches before replay
|
||||
and reuses them during the following initialisation hook.
|
||||
|
||||
The existing boolean optional API remains a legacy notification contract for
|
||||
other callers and older hosts. Once a host registers typed providers, the
|
||||
manager dispatches through that chain exclusively. Without a typed provider,
|
||||
a resolved legacy notification completes that dispatch only; its boolean does
|
||||
not establish a database save. Snapshot-write failures remain visible under
|
||||
both contracts. No persistent journal, new snapshot schema, or automatic retry
|
||||
loop is introduced.
|
||||
|
||||
### 2. Keep a recoverable record through processing
|
||||
|
||||
The event manager owns each operation from queue admission through completion.
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||
"test:e2e:obsidian:optional-file-save-completion": "tsx test/e2e-obsidian/scripts/optional-file-save-completion.ts",
|
||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const content = "module.exports = class ExamplePlugin {};";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function getText(entry: Parameters<typeof readContent>[0]) {
|
||||
const value = await readContent(entry);
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function createHiddenFileSync() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
let fileContent = content;
|
||||
let stat: UXStat | null = {
|
||||
ctime: 1_000,
|
||||
mtime: 2_000,
|
||||
size: fileContent.length,
|
||||
type: "file",
|
||||
};
|
||||
const values = new Map<string, unknown>();
|
||||
const kvDB = {
|
||||
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
|
||||
set: vi.fn(async <T>(key: string, value: T) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
};
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async () => stat),
|
||||
readHiddenFileAuto: vi.fn(async () => fileContent),
|
||||
writeHiddenFileAuto: vi.fn(async () => undefined),
|
||||
removeHidden: vi.fn(async () => true),
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
ensureDir: vi.fn(async () => undefined),
|
||||
triggerHiddenFile: vi.fn(async () => undefined),
|
||||
setFile(nextContent: string | null, mtime = 2_000) {
|
||||
fileContent = nextContent ?? "";
|
||||
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
|
||||
},
|
||||
};
|
||||
const databaseService = { isDatabaseReady: vi.fn(() => true) };
|
||||
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
localDatabase: fixture.localDatabase,
|
||||
kvDB,
|
||||
storageAccess,
|
||||
services: {
|
||||
...fixture.services,
|
||||
database: databaseService,
|
||||
appLifecycle,
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
markChangesAreSame: vi.fn(),
|
||||
unmarkChanges: vi.fn(),
|
||||
},
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
_fileInfoLastProcessed: new Map(),
|
||||
_databaseInfoLastProcessed: new Map(),
|
||||
_fileInfoLastKnown: new Map(),
|
||||
semaphore: Semaphore(10),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Hidden File Sync optional save outcomes", () => {
|
||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
|
||||
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
|
||||
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary database failure"
|
||||
);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) expect(await getText(saved)).toBe(content);
|
||||
expect(appLifecycle.isReady()).toBe(false);
|
||||
expect(appLifecycle.isSuspended()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockImplementationOnce(async () => false as never);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
`Failed to store hidden file in the database: ${path}`
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(first).not.toBe(false);
|
||||
if (first) {
|
||||
const firstRevision = first._rev;
|
||||
hiddenFileSync._fileInfoLastProcessed.delete(path);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(unchanged).not.toBe(false);
|
||||
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
|
||||
}
|
||||
|
||||
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(updated).not.toBe(false);
|
||||
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
|
||||
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
|
||||
|
||||
databaseService.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
|
||||
databaseService.isDatabaseReady.mockReturnValue(true);
|
||||
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
fixture.settings.syncInternalFiles = true;
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
|
||||
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
|
||||
storageAccess.setFile(null);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
const lifecycle = hiddenFileSync as unknown as {
|
||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
|
||||
};
|
||||
hiddenFileSync.core.settings.syncInternalFiles = false;
|
||||
await lifecycle._everyOnDatabaseInitialized(false);
|
||||
expect(kvDB.get).toHaveBeenCalledTimes(3);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
});
|
||||
|
||||
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
|
||||
storageAccess.setFile(null);
|
||||
const putRaw = vi
|
||||
.spyOn(fixture.localDatabase, "putRaw")
|
||||
.mockRejectedValueOnce(new Error("temporary deletion failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary deletion failure"
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
hiddenFileSync.resetLastProcessedFile([path]);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
|
||||
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
|
||||
const deletedBase = await fixture.localDatabase.getDBEntry(
|
||||
`i:${path}` as FilePath,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(deletedBase).not.toBe(false);
|
||||
if (deletedBase) expect(deletedBase.deleted).toBe(true);
|
||||
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
|
||||
|
||||
storageAccess.setFile("", 4_000);
|
||||
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(1);
|
||||
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(recreated).not.toBe(false);
|
||||
if (recreated) {
|
||||
expect(recreated.deleted).not.toBe(true);
|
||||
expect(await getText(recreated)).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
@@ -161,9 +163,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
// return true;
|
||||
// }
|
||||
private async _everyOnDatabaseInitialized(showNotice: boolean) {
|
||||
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
|
||||
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
|
||||
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
|
||||
await this.ensureProcessedCaches();
|
||||
if (this.isThisModuleEnabled()) {
|
||||
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
|
||||
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
|
||||
@@ -249,6 +249,26 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
return false;
|
||||
}
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
_options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
|
||||
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) {
|
||||
return { kind: "veto", value: "The local database is not ready" };
|
||||
}
|
||||
|
||||
await this.ensureProcessedCaches();
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
if (!(await this.trackStorageFileModificationStrict(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
|
||||
if (isInternalMetadata(path)) {
|
||||
this.queueConflictCheck(path);
|
||||
@@ -317,6 +337,51 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
_fileInfoLastKnown!: MapLike<string, number>;
|
||||
_databaseInfoLastProcessed!: MapLike<string, string>;
|
||||
|
||||
private processedCacheStore: unknown;
|
||||
private processedCacheStoreReady = false;
|
||||
private processedCacheLoadStore: unknown;
|
||||
private processedCacheLoad?: Promise<void>;
|
||||
|
||||
private async ensureProcessedCaches() {
|
||||
const store = this.kvDB;
|
||||
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
|
||||
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
|
||||
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
|
||||
await this.processedCacheLoad;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let load!: Promise<void>;
|
||||
load = Promise.all([
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
|
||||
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
|
||||
])
|
||||
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
|
||||
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
|
||||
this._fileInfoLastProcessed = fileInfoLastProcessed;
|
||||
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
|
||||
this._fileInfoLastKnown = fileInfoLastKnown;
|
||||
this.processedCacheStore = store;
|
||||
this.processedCacheStoreReady = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.processedCacheLoad === load) {
|
||||
this.processedCacheLoad = undefined;
|
||||
this.processedCacheLoadStore = undefined;
|
||||
}
|
||||
});
|
||||
this.processedCacheLoadStore = store;
|
||||
this.processedCacheLoad = load;
|
||||
await load;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
}
|
||||
|
||||
statToKey(stat: UXStat | null) {
|
||||
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
|
||||
}
|
||||
@@ -600,7 +665,6 @@ Offline Changed files: ${processFiles.length}`;
|
||||
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
|
||||
stat = cache.stat;
|
||||
}
|
||||
this.updateLastProcessedFile(path, stat!);
|
||||
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
|
||||
const nowIsNotFound = cache.deleted;
|
||||
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
|
||||
@@ -651,6 +715,27 @@ Offline Changed files: ${processFiles.length}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
|
||||
return await this.serializedForEvent(path, async () => {
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat != null && stat.type != "file") return true;
|
||||
const key = await this.fileToStatKey(path, stat);
|
||||
if (this.getLastProcessedFileKey(path) === key) {
|
||||
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileInfo = await this.loadFileWithInfo(path);
|
||||
if (fileInfo.deleted) {
|
||||
await this.deleteInternalFileOnDatabaseStrict(path);
|
||||
return true;
|
||||
}
|
||||
await this.storeInternalFileToDatabaseStrict(fileInfo);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --> Conflict processing
|
||||
|
||||
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
|
||||
@@ -1500,6 +1585,18 @@ Offline Changed files: ${files.length}`;
|
||||
// --> Storage To Database Functions
|
||||
|
||||
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, false);
|
||||
}
|
||||
|
||||
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, true);
|
||||
}
|
||||
|
||||
private async loadBaseSaveData(
|
||||
file: FilePath,
|
||||
includeContent: boolean,
|
||||
strict: boolean
|
||||
): Promise<LoadedEntry | false> {
|
||||
const prefixedFileName = addPrefix(file, ICHeader);
|
||||
const id = await this.path2id(prefixedFileName, ICHeader);
|
||||
try {
|
||||
@@ -1525,6 +1622,7 @@ Offline Changed files: ${files.length}`;
|
||||
return old;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (strict) throw ex;
|
||||
this._log(`Getting base save data failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -1552,6 +1650,18 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseCore(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
forceWrite: boolean,
|
||||
strict: boolean
|
||||
) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1565,9 +1675,11 @@ Offline Changed files: ${files.length}`;
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, true)
|
||||
: await this.__loadBaseSaveData(storeFilePath, true);
|
||||
if (baseData === false) throw new Error("Failed to load base data");
|
||||
if (baseData._rev && !forceWrite) {
|
||||
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
|
||||
// Not newly created, we should check the content is actually modified.
|
||||
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
|
||||
if (isSame) {
|
||||
@@ -1592,11 +1704,15 @@ Offline Changed files: ${files.length}`;
|
||||
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
|
||||
}
|
||||
const success = ret && ret.ok;
|
||||
if (!success && strict) {
|
||||
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
|
||||
}
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
|
||||
return success;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1676,6 +1792,14 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
const displayFileName = filenameSrc;
|
||||
@@ -1686,18 +1810,26 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, false)
|
||||
: await this.__loadBaseSaveData(storeFilePath, false);
|
||||
if (baseData === false) throw new Error("Failed to load base data during deleting");
|
||||
if (baseData._conflicts !== undefined) {
|
||||
for (const conflictRev of baseData._conflicts) {
|
||||
await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
if (strict && removed === false) {
|
||||
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
|
||||
}
|
||||
this._log(
|
||||
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
}
|
||||
if (baseData.deleted) {
|
||||
const alreadyDeleted = strict
|
||||
? !baseData._rev || baseData.deleted || baseData._deleted
|
||||
: baseData.deleted;
|
||||
if (alreadyDeleted) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
|
||||
this.updateLastProcessedDeletion(storeFilePath, baseData);
|
||||
return true;
|
||||
@@ -1718,11 +1850,13 @@ Offline Changed files: ${files.length}`;
|
||||
return true;
|
||||
} else {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -2084,6 +2218,9 @@ Offline Changed files: ${files.length}`;
|
||||
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -256,6 +256,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
||||
|
||||
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
`test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`.
|
||||
|
||||
`test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary.
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Exercises optional file admission, actual save completion, and replay in a real Obsidian Vault. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { requireObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cliBinary = requireObsidianCli();
|
||||
const vault = await createTemporaryVault("obsidian-optional-save-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
const observations: unknown[] = [];
|
||||
const bundleHash = createHash("sha256")
|
||||
.update(new Uint8Array(await readFile("main.js")))
|
||||
.digest("hex");
|
||||
const settings = createE2eCouchDbPluginData(
|
||||
// These scenarios use only the local database; every replication trigger is disabled.
|
||||
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "optional-save-local" },
|
||||
{
|
||||
syncInternalFiles: true,
|
||||
usePluginSync: true,
|
||||
usePluginSyncV2: true,
|
||||
deviceAndVaultName: "optional-save-device",
|
||||
watchInternalFileChanges: true,
|
||||
syncInternalFilesTargetPatterns: "^\\.obsidian(?:$|/(?:optional-save|snippets)(?:/|$))",
|
||||
syncInternalFilesIgnorePatterns: "",
|
||||
autoSweepPlugins: false,
|
||||
autoSweepPluginsPeriodic: false,
|
||||
syncInternalFilesInterval: 0,
|
||||
liveSync: false,
|
||||
periodicReplication: false,
|
||||
syncOnStart: false,
|
||||
syncOnSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnEditorSave: false,
|
||||
syncAfterMerge: false,
|
||||
showVerboseLog: true,
|
||||
}
|
||||
);
|
||||
|
||||
async function start(restart = false): Promise<void> {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cliBinary,
|
||||
vault,
|
||||
pluginStartup: restart ? "natural" : undefined,
|
||||
pluginData: restart ? undefined : settings,
|
||||
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(vault.name),
|
||||
lifecycle: {
|
||||
beforeLaunch: async () => {
|
||||
const registryPath = join(vault.userDataPath, "obsidian.json");
|
||||
const registry = JSON.parse(await readFile(registryPath, "utf8"));
|
||||
registry.updateDisabled = true;
|
||||
await writeFile(registryPath, JSON.stringify(registry));
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
}
|
||||
|
||||
async function evaluate<T>(body: string): Promise<T> {
|
||||
if (!session) throw new Error("The Obsidian session is not running");
|
||||
return await evalObsidianJson<T>(
|
||||
cliBinary,
|
||||
`(async()=>{
|
||||
const core=app.plugins.plugins['obsidian-livesync'].core;
|
||||
const manager=core.serviceModules.storageAccess.vaultManager;
|
||||
const config=core.getAddOn('ConfigSync');
|
||||
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
||||
const require=(condition,message)=>{if(!condition)throw new Error(message);};
|
||||
const waitFor=async (predicate,message)=>{
|
||||
const until=Date.now()+10000;
|
||||
while(!(await predicate())) {
|
||||
if(Date.now()>until)throw new Error(message);
|
||||
await sleep(20);
|
||||
}
|
||||
};
|
||||
const pending=async path=>(await manager.adapter.persistence.loadSnapshot()??[])
|
||||
.some(event=>event.args?.file?.path===path);
|
||||
const entry=async path=>await core.localDatabase.getDBEntry(path,undefined,false,true);
|
||||
const contents=doc=>Array.isArray(doc.data)?doc.data.join(''):doc.data;
|
||||
const represents=(doc,content)=>doc&& !doc.deleted &&
|
||||
(contents(doc).includes(content)||contents(doc).includes(btoa(content)));
|
||||
const notify=async path=>{
|
||||
await manager._watchVaultRawEvents(path);
|
||||
await sleep(30);
|
||||
};
|
||||
${body}
|
||||
})()`,
|
||||
session.cliEnv
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await start();
|
||||
await evaluate(`
|
||||
require(core.services.fileProcessing.processOptionalFileEventWithResult?.hasHandlers,
|
||||
'Typed optional providers were not registered');
|
||||
core.services.setting.setDeviceAndVaultName('optional-save-device');
|
||||
await core.services.setting.saveSettingData();
|
||||
for(const path of ['.obsidian/optional-save','.obsidian/snippets']) {
|
||||
if(!(await app.vault.adapter.exists(path)))await app.vault.adapter.mkdir(path);
|
||||
}
|
||||
return JSON.stringify({ready:true});
|
||||
`);
|
||||
|
||||
for (const mode of ["hidden", "config-v1", "config-v2"]) {
|
||||
observations.push(
|
||||
await evaluate(`
|
||||
const mode=${JSON.stringify(mode)};
|
||||
core.settings.usePluginSyncV2=mode!=='config-v1';
|
||||
await core.services.setting.saveSettingData();
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(),'Initial flush failed');
|
||||
const path=mode==='hidden'?'.obsidian/optional-save/content.json':
|
||||
'.obsidian/snippets/optional-save-'+mode+'.css';
|
||||
const databasePath=mode==='hidden'?'i:'+path:mode==='config-v1'?
|
||||
config.filenameToUnifiedKey(path):config.filenameWithUnifiedKey(path);
|
||||
const content='optional save '+mode+' initial';
|
||||
const original=core.localDatabase.putDBEntry.bind(core.localDatabase);
|
||||
let release;
|
||||
const gate=new Promise(resolve=>{release=resolve;});
|
||||
let started=false;
|
||||
core.localDatabase.putDBEntry=async data=>{
|
||||
if(data.path===databasePath){started=true;await gate;}
|
||||
return await original(data);
|
||||
};
|
||||
try {
|
||||
await app.vault.adapter.write(path,content);
|
||||
await notify(path);
|
||||
await waitFor(()=>started,'The optional database save did not start: '+mode);
|
||||
require(await pending(path),'The in-flight optional save was missing from the snapshot: '+mode);
|
||||
let flushed=false;
|
||||
const flush=core.services.fileProcessing.commitPendingFileEvents().then(result=>{flushed=true;return result;});
|
||||
await sleep(30);
|
||||
require(!flushed,'Flush returned before the actual database save: '+mode);
|
||||
release();
|
||||
require(await flush,'Flush failed after the write was released: '+mode);
|
||||
} finally {
|
||||
release();
|
||||
core.localDatabase.putDBEntry=original;
|
||||
}
|
||||
const first=await entry(databasePath);
|
||||
require(represents(first,content),'The completed save did not contain the expected content: '+mode);
|
||||
await notify(path);
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(),'Unchanged notification failed: '+mode);
|
||||
require((await entry(databasePath))._rev===first._rev,'Unchanged notification added a revision: '+mode);
|
||||
const updated=content+' with a later edit';
|
||||
await app.vault.adapter.write(path,updated);
|
||||
await notify(path);
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(),'Later edit was blocked: '+mode);
|
||||
require(represents(await entry(databasePath),updated),'Later edit was not saved: '+mode);
|
||||
await app.vault.adapter.remove(path);
|
||||
await notify(path);
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(),'Deletion failed: '+mode);
|
||||
const deleted=await core.localDatabase.getDBEntryMeta(databasePath,undefined,true);
|
||||
require(deleted&&deleted.deleted,'Deletion was not represented: '+mode);
|
||||
await notify(path);
|
||||
require(await core.services.fileProcessing.commitPendingFileEvents(),'Repeated deletion failed: '+mode);
|
||||
require((await core.localDatabase.getDBEntryMeta(databasePath,undefined,true))._rev===deleted._rev,
|
||||
'Repeated deletion added a revision: '+mode);
|
||||
return JSON.stringify({mode,saveWaited:true,unchangedPreservedRevision:true,laterEditSaved:true,deletionReplayed:true});
|
||||
`)
|
||||
);
|
||||
}
|
||||
|
||||
const replay = [
|
||||
{ path: ".obsidian/optional-save/replay.json", content: "hidden save retained across restart" },
|
||||
{
|
||||
path: ".obsidian/snippets/optional-save-replay.css",
|
||||
content: "customisation save retained across restart",
|
||||
},
|
||||
];
|
||||
observations.push(
|
||||
await evaluate(`
|
||||
const fixtures=${JSON.stringify(replay)};
|
||||
core.settings.usePluginSyncV2=true;
|
||||
await core.services.setting.saveSettingData();
|
||||
const failures=new Set(fixtures.map(({path})=>path.endsWith('.json')?'i:'+path:config.filenameWithUnifiedKey(path)));
|
||||
const original=core.localDatabase.putDBEntry.bind(core.localDatabase);
|
||||
let failed=0;
|
||||
core.localDatabase.putDBEntry=async data=>{
|
||||
if(failures.delete(data.path)){failed++;return false;}
|
||||
return await original(data);
|
||||
};
|
||||
try {
|
||||
for(const {path,content} of fixtures) {
|
||||
await app.vault.adapter.write(path,content);
|
||||
await notify(path);
|
||||
}
|
||||
await waitFor(()=>failed===fixtures.length,'Injected optional failures were not reached');
|
||||
require(!(await core.services.fileProcessing.commitPendingFileEvents()),'Failed saves were acknowledged');
|
||||
for(const {path} of fixtures)require(await pending(path),'Failed save was lost: '+path);
|
||||
} finally {
|
||||
core.localDatabase.putDBEntry=original;
|
||||
}
|
||||
return JSON.stringify({failedSavesRetained:failed});
|
||||
`)
|
||||
);
|
||||
await session!.app.stop();
|
||||
session = undefined;
|
||||
await start(true);
|
||||
observations.push(
|
||||
await evaluate(`
|
||||
const fixtures=${JSON.stringify(replay)};
|
||||
for(const {path,content} of fixtures) {
|
||||
const databasePath=path.endsWith('.json')?'i:'+path:config.filenameWithUnifiedKey(path);
|
||||
require(represents(await entry(databasePath),content),'Start-up did not recover '+path);
|
||||
require(!(await pending(path)),'Recovered save is still pending: '+path);
|
||||
}
|
||||
return JSON.stringify({replayedBeforeReadiness:fixtures.length,ready:core.services.appLifecycle.isReady()});
|
||||
`)
|
||||
);
|
||||
console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2));
|
||||
} finally {
|
||||
await session?.app.stop();
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -24,6 +24,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
- Local changes remain available for recovery while their database save is running, including when the application stops after a batch delay ends.
|
||||
- Manual synchronisation and settings application now wait for the accepted local saves to finish and report failures. Pending events retain the existing snapshot format.
|
||||
- Hidden File Sync and Customisation Sync now report completion after saving, including delayed customisation updates. Failed saves remain pending, while unchanged notifications allow later edits to proceed.
|
||||
- Start-up reconciliation checks content when modification times are equal, so an edit within the same timestamp interval is not mistaken for an unchanged file.
|
||||
- Existing revision provenance preserves the correct edit branch. Unreadable or continually changing content remains unresolved rather than being reported as successfully reconciled.
|
||||
- We can run startup integrity checks with lower memory use for healthy Vault files. Files that change between the size check and content reading no longer trigger an unnecessary repair warning when their actual sizes agree and there are no conflicts. (#1283)
|
||||
|
||||
Reference in new issue
Block a user