Merge origin/main into time-bound Setup URI branch

This commit is contained in:
vorotamoroz committed 2026-09-28 10:29:11 +00:00
commit 03d8d3307d
22 files changed
+410 -105

No files matched your search

@@ -190,6 +190,20 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
log: LogFunction,
cleanupFlag: () => Promise<void>
): Promise<boolean | undefined> {
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);
+18
View File
@@ -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();
@@ -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()
);
@@ -1,7 +1,11 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { VER, type EntryDoc, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
isValidFilenameInAndroid,
isValidFilenameInWidows,
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import {
defaultLogger,
LOG_LEVEL_DEBUG,
@@ -28,6 +32,9 @@ function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
type SetupOptions = {
applicationReady?: boolean;
databaseReady?: boolean;
maxMTimeForReflectEvents?: number;
isValidPath?: (path: string) => boolean;
processSynchroniseResult?: (entry: unknown) => Promise<boolean>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
};
@@ -38,9 +45,12 @@ function setup(options: SetupOptions = {}) {
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
const onCloseActiveReplication = vi.fn(async () => true);
const isReady = vi.fn(() => options.applicationReady ?? true);
const isValidPath = vi.fn(options.isValidPath ?? (() => true));
const getDBEntryFromMeta = vi.fn(async (entry: object) => ({ ...entry, data: "x" }));
const core = {
services: {
appLifecycle: { isReady, isSuspended: () => false },
database: { isDatabaseReady: () => options.databaseReady ?? true },
path: { getPath: (entry: { path: string }) => entry.path },
replication: {
databaseQueueCount: reactiveSource(0),
@@ -54,17 +64,20 @@ function setup(options: SetupOptions = {}) {
vault: {
isTargetFile: vi.fn(async () => true),
isFileSizeTooLarge: vi.fn(() => false),
isValidPath: vi.fn(() => true),
isValidPath,
},
},
kvDB: { set: setSnapshot },
localDatabase: {
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
getDBEntryFromMeta,
},
};
const processor = new ReplicateResultProcessor({
currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }),
currentSettings: () => ({
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
suspendParseReplicationResult: false,
}),
getKeyValueDB: () => core.kvDB,
getLocalDatabase: () => core.localDatabase,
requestActiveReplicatorRetirement: () => {
@@ -74,7 +87,9 @@ function setup(options: SetupOptions = {}) {
services: core.services,
} as never);
return {
getDBEntryFromMeta,
isReady,
isValidPath,
onCloseActiveReplication,
processor,
processSynchroniseResult,
@@ -83,6 +98,24 @@ function setup(options: SetupOptions = {}) {
}
describe("ReplicateResultProcessor", () => {
it.each([
["Windows", isValidFilenameInWidows],
["Android", isValidFilenameInAndroid],
])("does not reflect a replicated colon path into the %s Vault", async (_platform, validatePath) => {
const path = "Folder/Poem: Example.md" as FilePathWithPrefix;
const document = { ...note("colon-path"), path };
const { getDBEntryFromMeta, isValidPath, processor, processSynchroniseResult } = setup({
isValidPath: validatePath,
});
processor.enqueueAll([document]);
await vi.waitFor(() => expect(isValidPath).toHaveBeenCalledWith(path));
await vi.waitFor(() => expect(processor["_processingChanges"]).toHaveLength(0));
expect(getDBEntryFromMeta).toHaveBeenCalledWith(expect.objectContaining({ path }), false, true);
expect(processSynchroniseResult).not.toHaveBeenCalled();
});
it("resumes another document after in-flight updates to one document fill the application slots", async () => {
const hotGate = promiseWithResolvers<boolean>();
const { processor, processSynchroniseResult } = setup({
@@ -124,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<EntryDoc>;
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 = {
+1
View File
@@ -51,6 +51,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
: await task(),
services: {
appLifecycle: services.appLifecycle,
database: services.database,
path: services.path,
replication: services.replication,
vault: services.vault,