Merge branch 'main' into pr/zeedif/1015

This commit is contained in:
vorotamoroz
2026-07-31 07:14:14 +01:00
26 changed files with 4656 additions and 805 deletions
+57 -1
View File
@@ -24,9 +24,16 @@ import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type PouchDB from "pouchdb-core";
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
const REPROCESS_BATCH_SIZE = 100;
type LocalApplicationActivityOwner = {
runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: { label?: string }
): Promise<T>;
};
type ReplicateResultProcessorState = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
@@ -67,9 +74,11 @@ export class ReplicateResultProcessor {
public suspend() {
this._suspended = true;
this.updateProcessingActivity();
}
public resume() {
this._suspended = false;
this.updateProcessingActivity();
fireAndForget(() => this.runProcessQueue());
}
@@ -251,6 +260,40 @@ export class ReplicateResultProcessor {
*/
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
private _processingActivity?: Promise<void>;
private _processingActivityDone?: PromiseWithResolvers<void>;
private updateProcessingActivity() {
if (this.isSuspended) {
this._processingActivityDone?.resolve();
return;
}
const hasPendingDocuments = this._queuedChanges.length > 0 || this._processingChanges.length > 0;
if (!hasPendingDocuments) {
this._processingActivityDone?.resolve();
return;
}
if (this._processingActivity) return;
const activityDone = promiseWithResolvers<void>();
this._processingActivityDone = activityDone;
const activityOwner = this.services.replicator as typeof this.services.replicator &
Partial<LocalApplicationActivityOwner>;
this._processingActivity = (
activityOwner.runBoundedLocalApplicationActivity
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
label: "replicated-document-application",
})
: activityDone.promise
)
.catch((error) => this.logError(error))
.finally(() => {
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
this._processingActivity = undefined;
this.updateProcessingActivity();
});
}
/**
* Enqueue the given document change for processing.
* @param doc Document change to enqueue
@@ -278,6 +321,7 @@ export class ReplicateResultProcessor {
}
// Enqueue the change
this._queuedChanges.push(doc);
this.updateProcessingActivity();
this.triggerTakeSnapshot();
this.triggerProcessQueue();
}
@@ -385,7 +429,19 @@ export class ReplicateResultProcessor {
} finally {
// Remove from processing queue
this._processingChanges = this._processingChanges.filter((e) => e !== change);
this.triggerTakeSnapshot();
try {
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
try {
await this._takeSnapshot();
} catch (error) {
this.logError(error);
}
} else {
this.triggerTakeSnapshot();
}
} finally {
this.updateProcessingActivity();
}
}
}
@@ -1,8 +1,67 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
describe("ReplicateResultProcessor target-filter reprocessing", () => {
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
return {
_id: id,
_rev: "1-test",
path: `${id}.md`,
ctime: 1,
mtime: 2,
size: 1,
children: [],
datatype: "plain",
type: "plain",
eden: {},
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
}
type SetupOptions = {
processSynchroniseResult?: (entry: unknown) => Promise<void>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
};
function setup(options: SetupOptions = {}) {
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
const core = {
services: {
appLifecycle: { isReady: true, isSuspended: () => false },
path: { getPath: (entry: { path: string }) => entry.path },
replication: {
databaseQueueCount: reactiveSource(0),
storageApplyingCount: reactiveSource(0),
replicationResultCount: reactiveSource(0),
processVirtualDocument: vi.fn(async () => false),
processOptionalSynchroniseResult: vi.fn(async () => false),
processSynchroniseResult,
},
replicator: { runBoundedLocalApplicationActivity },
vault: {
isTargetFile: vi.fn(async () => true),
isFileSizeTooLarge: vi.fn(() => false),
isValidPath: vi.fn(() => true),
},
},
kvDB: { set: setSnapshot },
localDatabase: {
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
},
replicator: { closeReplication: vi.fn() },
};
const processor = new ReplicateResultProcessor({
core,
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
} as never);
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
}
describe("ReplicateResultProcessor", () => {
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
const documents = [
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
@@ -22,4 +81,67 @@ describe("ReplicateResultProcessor target-filter reprocessing", () => {
expect(enqueueAll).toHaveBeenCalledOnce();
expect(enqueueAll).toHaveBeenCalledWith(documents);
});
it("keeps one local application activity until every replicated document has been applied", async () => {
const applying = promiseWithResolvers<void>();
let activityFinished = false;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one"), note("two")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(2));
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(1);
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
label: "replicated-document-application",
});
expect(activityFinished).toBe(false);
applying.resolve();
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("settles local application activity when the final recovery snapshot fails", async () => {
let activityFinished = false;
const { processor, runBoundedLocalApplicationActivity } = setup({
setSnapshot: async () => Promise.reject(new Error("snapshot failed")),
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("releases and reacquires local application activity around processing suspension", async () => {
const applying = promiseWithResolvers<void>();
let completedActivities = 0;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
completedActivities++;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
processor.suspend();
await vi.waitFor(() => expect(completedActivities).toBe(1));
processor.resume();
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
applying.resolve();
await vi.waitFor(() => expect(completedActivities).toBe(2));
});
});
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
hasFocus = true;
isLastHidden = false;
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
private get boundedActivityCounts(): ReactiveSource<number>[] {
const replicator = this.services.replicator as typeof this.services.replicator & {
boundedLocalApplicationActivityCount: ReactiveSource<number>;
};
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
}
private hasBoundedActivity() {
return this.boundedActivityCounts.some((count) => count.value > 0);
}
private keepReplicationActiveInBackground() {
return (
this.settings.keepReplicationActiveInBackground &&
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
private async applyDeferredBoundedActivityLifecycle() {
const count = this.services.replicator.boundedRemoteActivityCount;
if (count.value !== 0) {
this.deferLifecycleUntilBoundedRemoteActivityEnds();
if (this.hasBoundedActivity()) {
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
const deferredLifecycle = this.deferredBoundedLifecycle;
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
}
private deferLifecycleUntilBoundedRemoteActivityEnds() {
if (this.boundedRemoteActivityEndHandler) return;
const count = this.services.replicator.boundedRemoteActivityCount;
const handler = (value: { readonly value: number }) => {
if (value.value !== 0) return;
count.offChanged(handler);
this.boundedRemoteActivityEndHandler = undefined;
private deferLifecycleUntilBoundedActivityEnds() {
if (this.boundedActivityEndHandler) return;
const counts = this.boundedActivityCounts;
const handler = () => {
if (this.hasBoundedActivity()) return;
for (const count of counts) count.offChanged(handler);
this.boundedActivityEndHandler = undefined;
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
};
this.boundedRemoteActivityEndHandler = handler;
count.onChanged(handler);
this.boundedActivityEndHandler = handler;
for (const count of counts) count.onChanged(handler);
}
setHasFocus(hasFocus: boolean) {
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (
this.settings.isConfigured &&
this.services.appLifecycle.isReady() &&
this.services.replicator.boundedRemoteActivityCount.value > 0
this.hasBoundedActivity()
) {
const isHidden = activeWindow.document.hidden;
this.isLastHidden = isHidden;
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
}
return;
}
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
return;
}
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
const boundedActivityInProgress = this.hasBoundedActivity();
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
this.isLastHidden = false;
this.deferredBoundedLifecycle = undefined;
return;
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
const keepActiveInBackground = this.keepReplicationActiveInBackground();
if (isHidden) {
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
if (boundedActivityInProgress && !keepActiveInBackground) {
this.deferredBoundedLifecycle = "suspend-if-hidden";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
} else if (!keepActiveInBackground) {
await this.services.appLifecycle.onSuspending();
}
} else {
// suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return;
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
const core = {
_services: {
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replicator: { boundedRemoteActivityCount },
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
...DEFAULT_SETTINGS,
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
return {
module,
appLifecycle,
fileProcessing,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers suspension while local document application is active", async () => {
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedLocalApplicationActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
boundedLocalApplicationActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers mobile suspension while bounded remote activity is running", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
@@ -74,6 +74,11 @@ export class DocumentHistoryModal extends Modal {
currentDeleted = false;
initialRev?: string;
// Revision navigation state (◀/▶ beside the range slider)
revPrevBtn!: HTMLButtonElement;
revNextBtn!: HTMLButtonElement;
revNavIndicator!: HTMLSpanElement;
// Diff navigation state
currentDiffIndex = -1;
diffNavContainer!: HTMLDivElement;
@@ -84,6 +89,8 @@ export class DocumentHistoryModal extends Modal {
searchKeyword = "";
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
currentSearchIndex = -1;
searchPrevBtn!: HTMLButtonElement;
searchNextBtn!: HTMLButtonElement;
searchResultIndicator!: HTMLSpanElement;
searchProgressIndicator!: HTMLSpanElement;
searchTimeout: number | null = null;
@@ -125,12 +132,14 @@ export class DocumentHistoryModal extends Modal {
this.range.value = this.range.max;
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
await this.loadRevs(initialRev);
this.updateRevisionNavUI();
} catch (ex) {
if (isErrorOfMissingDoc(ex)) {
this.range.max = "0";
this.range.value = "";
this.range.disabled = true;
this.contentView.setText(`We don't have any history for this note.`);
this.updateRevisionNavUI();
} else {
this.contentView.setText(`Error while loading file.`);
Logger(ex, LOG_LEVEL_VERBOSE);
@@ -148,6 +157,37 @@ export class DocumentHistoryModal extends Modal {
const index = this.revs_info.length - 1 - (Number(this.range.value) || 0);
const rev = this.revs_info[index];
await this.showExactRev(rev.rev);
this.updateRevisionNavUI();
}
navigateVersion(direction: "older" | "newer") {
const current = Number(this.range.value) || 0;
const max = Number(this.range.max) || 0;
if (direction === "older" && current > 0) {
this.range.value = `${current - 1}`;
} else if (direction === "newer" && current < max) {
this.range.value = `${current + 1}`;
} else {
return;
}
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
}
updateRevisionNavUI() {
if (!this.revNavIndicator) return;
const total = this.revs_info.length;
const max = Number(this.range.max) || 0;
const current = Number(this.range.value) || 0;
this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014");
const disabled = !!this.range.disabled || total <= 1;
this.revPrevBtn.disabled = disabled || current <= 0;
this.revNextBtn.disabled = disabled || current >= max;
}
BlobURLs = new Map<string, string>();
@@ -391,6 +431,7 @@ export class DocumentHistoryModal extends Modal {
if (!keyword) {
this.searchResultIndicator.setText("");
this.searchProgressIndicator.setText("");
this.updateSearchUI();
return;
}
@@ -464,6 +505,10 @@ export class DocumentHistoryModal extends Modal {
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
}
const hasResults = this.searchResults.length > 0;
this.searchPrevBtn.disabled = !hasResults;
this.searchNextBtn.disabled = !hasResults;
}
navigateSearch(direction: "prev" | "next") {
@@ -515,12 +560,14 @@ export class DocumentHistoryModal extends Modal {
}, 500);
});
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => {
e.title = "Previous match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("prev"));
});
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => {
e.title = "Next match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("next"));
});
@@ -530,18 +577,37 @@ export class DocumentHistoryModal extends Modal {
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
this.searchProgressIndicator.addClass("history-search-progress-indicator");
const divView = contentEl.createDiv("");
divView.addClass("op-flex");
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
divView.createEl("input", { type: "range" }, (e) => {
this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Older revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("older"));
});
revNavRow.createEl("input", { type: "range" }, (e) => {
this.range = e;
e.addEventListener("change", (e) => {
e.addEventListener("change", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
e.addEventListener("input", (e) => {
e.addEventListener("input", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
});
this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Newer revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("newer"));
});
this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => {
e.addClass("history-rev-indicator");
});
const diffOptionsRow = contentEl.createDiv("");
diffOptionsRow.addClass("op-info");
diffOptionsRow.addClass("diff-options-row");
+22 -1
View File
@@ -10,11 +10,32 @@ import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/se
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
type ActivityOptions = {
label?: string;
};
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
// InjectableReplicatorService
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {}
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {
readonly boundedLocalApplicationActivityCount = reactiveSource(0);
async runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: ActivityOptions
): Promise<T> {
this.boundedLocalApplicationActivityCount.value++;
try {
return this.dependencies.activityRunner
? await this.dependencies.activityRunner.run(task, options)
: await task();
} finally {
this.boundedLocalApplicationActivityCount.value--;
}
}
}
// InjectableFileProcessingService
export class ObsidianFileProcessingService extends InjectableFileProcessingService<ObsidianServiceContext> {}
// InjectableReplicationService
@@ -0,0 +1,35 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { describe, expect, it, vi } from "vitest";
import { ObsidianReplicatorService } from "./ObsidianServices";
function handler() {
return { addHandler: vi.fn() };
}
describe("ObsidianReplicatorService", () => {
it("tracks local application activity without extending remote activity", async () => {
const activity = promiseWithResolvers<void>();
const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, {
settingService: { onRealiseSetting: handler() },
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
databaseEventService: {
onResetDatabase: handler(),
onDatabaseInitialisation: handler(),
onDatabaseInitialised: handler(),
onDatabaseHasReady: handler(),
},
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
} as never);
const running = service.runBoundedLocalApplicationActivity(() => activity.promise);
expect(service.boundedLocalApplicationActivityCount.value).toBe(1);
expect(service.boundedRemoteActivityCount.value).toBe(0);
activity.resolve();
await running;
expect(service.boundedLocalApplicationActivityCount.value).toBe(0);
expect(service.boundedRemoteActivityCount.value).toBe(0);
});
});