mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-09 02:42:30 +00:00
Merge origin/main into time-bound Setup URI branch
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.30-cli",
|
||||
"version": "1.0.32-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.30-webapp",
|
||||
"version": "1.0.32-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.30-webpeer",
|
||||
"version": "1.0.32-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
normalizePath: vi.fn((path: string) => `normalised(${path})`),
|
||||
path2idBase: vi.fn(async (path: string) => path),
|
||||
id2pathBase: vi.fn((path: string) => path),
|
||||
expandFilePathPrefix: vi.fn((path: string): [string, string] => {
|
||||
if (path.startsWith("i:")) return ["i:", path.substring(2)];
|
||||
return ["", path];
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
normalizePath: mocks.normalizePath,
|
||||
Platform: {},
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/string_and_binary/path", () => ({
|
||||
path2id_base: mocks.path2idBase,
|
||||
id2path_base: mocks.id2pathBase,
|
||||
expandFilePathPrefix: mocks.expandFilePathPrefix,
|
||||
isValidFilenameInLinux: vi.fn(),
|
||||
isValidFilenameInDarwin: vi.fn(),
|
||||
isValidFilenameInWidows: vi.fn(),
|
||||
isValidFilenameInAndroid: vi.fn(),
|
||||
stripAllPrefixes: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("path ID normalisation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("normalises the complete path body for %s", async (filename, prefix, body) => {
|
||||
const { path2id } = await import("./utils.ts");
|
||||
|
||||
const result = await path2id(filename as never, false, false);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(mocks.path2idBase).toHaveBeenCalledWith(`${prefix}normalised(${body})`, false, false);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Folder/Note.md", "", "Folder/Note.md"],
|
||||
["Folder/Poem: Example.md", "", "Folder/Poem: Example.md"],
|
||||
["Folder/Poem: Example: Final Draft.md", "", "Folder/Poem: Example: Final Draft.md"],
|
||||
["i:Folder/Poem: Example.md", "i:", "Folder/Poem: Example.md"],
|
||||
])("preserves the path namespace while normalising %s", async (filename, prefix, body) => {
|
||||
mocks.id2pathBase.mockReturnValue(filename);
|
||||
const { id2path } = await import("./utils.ts");
|
||||
|
||||
const result = id2path(filename as never);
|
||||
|
||||
expect(mocks.normalizePath).toHaveBeenCalledWith(body);
|
||||
expect(result).toBe(`${prefix}normalised(${body})`);
|
||||
});
|
||||
});
|
||||
+5
-8
@@ -7,6 +7,7 @@ import {
|
||||
isValidFilenameInWidows,
|
||||
isValidFilenameInAndroid,
|
||||
stripAllPrefixes,
|
||||
expandFilePathPrefix,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
@@ -41,22 +42,18 @@ export async function path2id(
|
||||
obfuscatePassphrase: string | false,
|
||||
caseInsensitive: boolean
|
||||
): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
|
||||
const out = await path2id_base(fixedPath, obfuscatePassphrase, caseInsensitive);
|
||||
return out;
|
||||
}
|
||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||
const filename = id2path_base(id, entry);
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const [prefix, path] = expandFilePathPrefix(filename);
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
const fixedPath = `${prefix}${normalizedPath}` as FilePathWithPrefix;
|
||||
return fixedPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user