diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 1578ab1c..90355a26 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -190,6 +190,20 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( log: LogFunction, cleanupFlag: () => Promise ): Promise { + if (host.services.setting.currentSettings().maxMTimeForReflectEvents > 0) { + // Simple Fetch reconciles storage with the local database after fetching, past the check which + // refuses that scan in remediation mode. Skipping only the scan would restore nothing from most + // remotes: reflection of received documents stays suspended while Simple Fetch fetches, so Object + // Storage and P2P remotes discard them, and CouchDB Fast Fetch writes them straight into the + // database. The detailed flow at least states the restriction and offers to clear it before + // rebuilding, instead of quietly reconciling past it. + log( + "Remediation mode is active, so the detailed fetch flow is used instead of Simple Fetch.", + LOG_LEVEL_NOTICE + ); + clearRememberedSimpleFetchMode(host); + return undefined; + } const result = await askSimpleFetchMode(host); if (result === "cancelled") { log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index ff5bcb7c..9c1a2bf8 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -771,6 +771,24 @@ describe("Red Flag Feature", () => { }); describe("askAndPerformFastSetupOnScheduledFetchAll", () => { + it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + const cleanupFlag = vi.fn().mockResolvedValue(undefined); + + Object.assign(host.mocks.setting.settings, { + maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"), + }); + + await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe( + undefined + ); + + expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled(); + expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode"); + expect(cleanupFlag).not.toHaveBeenCalled(); + }); + it("releases both reflection suspensions after Fast Setup succeeds", async () => { const host = createHostMock(); const log = createLoggerMock(); diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.ts index 9bd05505..e37f3c16 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.ts @@ -35,7 +35,7 @@ type ReplicateResultProcessorSettings = Pick< >; type ReplicateResultProcessorServices = Pick< LiveSyncBaseCore["services"], - "appLifecycle" | "path" | "replication" | "vault" + "appLifecycle" | "database" | "path" | "replication" | "vault" >; /** @@ -115,10 +115,27 @@ export class ReplicateResultProcessor { // If true, the processing queue processor bails the loop. private _suspended: boolean = false; + /** + * Whether the application accepts replicated documents being applied. + * + * Remediation mode refuses the reconciliation scan which readiness depends upon, so the + * application stays unready for as long as the modification-time limit is configured. + * Applying the received documents is what that mode exists for, and `parseDocumentChange` keeps + * each one within the limit, so readiness is not required while the mode is active. + */ + private get acceptsResultApplication() { + if (this.services.appLifecycle.isReady()) return true; + if (this.context.currentSettings().maxMTimeForReflectEvents <= 0) return false; + // A fetch resets the local database, and a remote which reflects while fetching leaves this + // processor unsuspended throughout. A document applied then cannot gather its chunks and is + // dropped, so the database itself must still be usable. + return this.services.database.isDatabaseReady(); + } + public get isSuspended() { return ( this._suspended || - !this.services.appLifecycle.isReady() || + !this.acceptsResultApplication || this.context.currentSettings().suspendParseReplicationResult || this.services.appLifecycle.isSuspended() ); diff --git a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts index 2fa97048..2cded809 100644 --- a/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts +++ b/src/serviceFeatures/replication/ReplicateResultProcessor.unit.spec.ts @@ -32,6 +32,8 @@ function note(id: string): PouchDB.Core.ExistingDocument { type SetupOptions = { applicationReady?: boolean; + databaseReady?: boolean; + maxMTimeForReflectEvents?: number; isValidPath?: (path: string) => boolean; processSynchroniseResult?: (entry: unknown) => Promise; setSnapshot?: (key: string, value: unknown) => Promise; @@ -48,6 +50,7 @@ function setup(options: SetupOptions = {}) { const core = { services: { appLifecycle: { isReady, isSuspended: () => false }, + database: { isDatabaseReady: () => options.databaseReady ?? true }, path: { getPath: (entry: { path: string }) => entry.path }, replication: { databaseQueueCount: reactiveSource(0), @@ -71,7 +74,10 @@ function setup(options: SetupOptions = {}) { }, }; const processor = new ReplicateResultProcessor({ - currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }), + currentSettings: () => ({ + maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0, + suspendParseReplicationResult: false, + }), getKeyValueDB: () => core.kvDB, getLocalDatabase: () => core.localDatabase, requestActiveReplicatorRetirement: () => { @@ -151,6 +157,45 @@ describe("ReplicateResultProcessor", () => { expect(isReady).toHaveBeenCalledOnce(); }); + it("applies results in remediation mode, which never reports readiness", () => { + const { processor } = setup({ + applicationReady: false, + maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"), + }); + + expect(processor.isSuspended).toBe(false); + }); + + it("holds results in remediation mode while the local database is being rebuilt", () => { + const { processor } = setup({ + applicationReady: false, + databaseReady: false, + maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"), + }); + + expect(processor.isSuspended).toBe(true); + }); + + it("still skips a document modified after the limit while the application is unready", async () => { + const maxMTimeForReflectEvents = Date.parse("2026-09-01T00:00:00Z"); + const { processor, processSynchroniseResult } = setup({ + applicationReady: false, + maxMTimeForReflectEvents, + }); + + const tooRecent = { + ...note("too-recent"), + mtime: maxMTimeForReflectEvents + 1, + } as PouchDB.Core.ExistingDocument; + processor.enqueueAll([tooRecent]); + + await vi.waitFor(() => { + expect(processor["_queuedChanges"]).toHaveLength(0); + expect(processor["_processingChanges"]).toHaveLength(0); + }); + expect(processSynchroniseResult).not.toHaveBeenCalled(); + }); + it("retires active ownership when a newer remote version is observed", async () => { const { onCloseActiveReplication, processor } = setup(); const versionInfo = { diff --git a/src/serviceFeatures/replication/index.ts b/src/serviceFeatures/replication/index.ts index a8046bd9..74a4f1e4 100644 --- a/src/serviceFeatures/replication/index.ts +++ b/src/serviceFeatures/replication/index.ts @@ -51,6 +51,7 @@ export function useReplicationFeature