Compare commits

...
6 Commits
21 changed files with 252 additions and 67 deletions
@@ -369,8 +369,17 @@ type InteractionAuthority =
const NO_INTERACTION = { kind: "forbidden" } as const;
const REPLICATION_PROGRESS_PRESENTATIONS = {
QUIET: "quiet",
NOTICE: "notice",
} as const;
type ReplicationProgressPresentation =
(typeof REPLICATION_PROGRESS_PRESENTATIONS)[keyof typeof REPLICATION_PROGRESS_PRESENTATIONS];
interface UserInitiatedOneShotRequest {
readonly trigger: "manual";
readonly progressPresentation: ReplicationProgressPresentation;
readonly interaction: InteractionAuthority;
}
@@ -393,11 +402,33 @@ same upper bound and expose only relevant permissions. An unattended path may
use persisted or automatic acceptance policy, but cannot obtain local
interaction authority implicitly.
The same authority bounds presentation. An unattended P2P path may retain an
informational diagnostic, but no-target, authentication, tweak-mismatch, and
overlapping-transfer settlements must not promote themselves to a Notice.
User-initiated paths retain their existing Notice-level presentation. This is
a caller-authority rule, not a new process-wide presentation framework.
Progress presentation and interaction authority are independent parts of the
request. `quiet` suppresses routine Notice-level progress, but does not remove
authorised peer selection or failure-recovery dialogue. `notice` permits
routine progress, but cannot grant an interaction which the request authority
forbids. Unattended work always uses `quiet` with `NO_INTERACTION`; it may retain
an informational diagnostic, but no-target, authentication, tweak-mismatch,
and overlapping-transfer settlements must not promote themselves to a Notice.
This remains a request-local policy, not a new process-wide presentation
framework.
The ordinary typed OneShot entry points deliberately retain these established
interaction shapes:
| Entry point | Routine progress | Interaction authority | Request received while another OneShot attempt is active |
| --- | --- | --- | --- |
| Command-palette `Sync now` | Quiet | Peer selection and failure recovery remain permitted | Settles as `blocked: replication-in-progress` |
| Ribbon synchronisation action | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| Config Sync `Sync once` | Notice | Full user-initiated authority | Settles as `blocked: replication-in-progress` |
| CLI `sync` | Notice | Full user-initiated authority, subject to the host's available interaction mechanisms | Settles as `blocked: replication-in-progress` |
| Resume, periodic, database-event, editor-save, file-open, merge, and daemon triggers | Quiet | `NO_INTERACTION` | Settles as `blocked: replication-in-progress` |
The OneShot owner is acquired before readiness evaluation and retained through
provider acquisition, finite activity, and failure handling. A later request
is not queued, joined to the running result, or treated as a failed transfer;
no work starts for that request. Directional Fetch and Rebuild, P2P-specific
commands, and other maintenance workflows remain outside this ordinary
OneShot gate and retain their own activity ownership.
Outcomes do not collapse failure into `void` or `boolean`:
+2
View File
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
The available actions depend on when the mismatch is found:
- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "obsidian-livesync",
"name": "Self-hosted LiveSync",
"version": "1.0.21",
"version": "1.0.23",
"minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz",
+35 -35
View File
@@ -1,12 +1,12 @@
{
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"license": "MIT",
"workspaces": [
"src/apps/cli",
@@ -23,7 +23,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.20",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
@@ -4620,9 +4620,9 @@
}
},
"node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.20",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.20.tgz",
"integrity": "sha512-d01aiejWGAH/8EDGeeZOUzMhTh14wV0IW2n6gpOPLzvTuQIA38lLMCweZN/n0YO/m2aa0TxBufe/YcYWRH6luA==",
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.808.0",
@@ -5186,9 +5186,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.37",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
"integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==",
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -5230,9 +5230,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -5250,11 +5250,11 @@
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
"electron-to-chromium": "^1.5.328",
"node-releases": "^2.0.36",
"update-browserslist-db": "^1.2.3"
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -5355,9 +5355,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -5812,9 +5812,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.375",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz",
"integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==",
"version": "1.5.419",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.419.tgz",
"integrity": "sha512-nHMPn8x4yCxCI0iSnL+LlHL5sUoUfjLXkcRIagZ4GBdrfFLFaiLNvzJWbJqZhFT9IAhw5tUSNlhggWN+otvp/A==",
"dev": true,
"license": "ISC"
},
@@ -9524,9 +9524,9 @@
}
},
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
"integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -10154,9 +10154,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -12292,9 +12292,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
@@ -12937,7 +12937,7 @@
},
"src/apps/cli": {
"name": "self-hosted-livesync-cli",
"version": "1.0.21-cli",
"version": "1.0.23-cli",
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
@@ -12962,7 +12962,7 @@
},
"src/apps/webapp": {
"name": "livesync-webapp",
"version": "1.0.21-webapp",
"version": "1.0.23-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
@@ -12974,7 +12974,7 @@
}
},
"src/apps/webpeer": {
"version": "1.0.21-webpeer",
"version": "1.0.23-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.53"
},
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "obsidian-livesync",
"version": "1.0.21",
"version": "1.0.23",
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"main": "main.js",
"type": "module",
@@ -177,7 +177,7 @@
"@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5",
"@vrtmrz/browser-ui-kit": "0.1.0",
"@vrtmrz/livesync-commonlib": "0.1.20",
"@vrtmrz/livesync-commonlib": "0.1.21",
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
"@vrtmrz/ui-interactions": "0.1.2",
"diff-match-patch": "^1.0.5",
+2
View File
@@ -24,6 +24,7 @@ import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
isReplicationCompleted,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -173,6 +174,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStdoutLine(standardIo, "[Command] sync");
const result = await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
if (!isReplicationCompleted(result)) {
@@ -19,6 +19,7 @@ import {
REMOTE_RESOURCE_KINDS,
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_COMPLETED,
REPLICATION_PROGRESS_PRESENTATIONS,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -265,6 +266,21 @@ describe("runCommand abnormal cases", () => {
vi.restoreAllMocks();
});
it("retains visible progress for the interactive sync command", async () => {
const core = createCoreMock();
await expect(
runCommand(makeOptions("sync", []), {
...context,
core,
})
).resolves.toBe(true);
expect(core.services.replication.replicateUserInitiated).toHaveBeenCalledWith(
expect.objectContaining({ progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE })
);
});
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
const core = createCoreMock();
core.services.replication.replicateUserInitiated.mockResolvedValue(
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "self-hosted-livesync-cli",
"private": true,
"version": "1.0.21-cli",
"version": "1.0.23-cli",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "livesync-webapp",
"private": true,
"version": "1.0.21-webapp",
"version": "1.0.23-webapp",
"type": "module",
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "webpeer",
"private": true,
"version": "1.0.21-webpeer",
"version": "1.0.23-webpeer",
"type": "module",
"scripts": {
"dev": "vite",
+3 -2
View File
@@ -3,6 +3,7 @@ import {
CAPABILITY_NOT_APPLICABLE,
CENTRAL_REMOTE_REPLICATION_READINESS,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
defineReplicatorProviderDefinitions,
supportedOpenReplicationContinuous,
@@ -72,7 +73,7 @@ const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance,
return await runOneShotWithOutcome(
instance,
setting,
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
@@ -85,7 +86,7 @@ const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (ins
return await runOneShotWithOutcome(
instance,
setting,
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
request.progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE
);
};
@@ -5,6 +5,7 @@ import {
CAPABILITY_SUPPORT_KINDS,
NO_INTERACTION,
REPLICATION_COMPLETED,
REPLICATION_PROGRESS_PRESENTATIONS,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -135,6 +136,7 @@ describe("central Replicator provider definitions", () => {
await expect(
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
})
).resolves.toBe(REPLICATION_COMPLETED);
@@ -149,6 +151,40 @@ describe("central Replicator provider definitions", () => {
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
});
it.each([
["CouchDB", REMOTE_COUCHDB],
["Object Storage", REMOTE_MINIO],
] as const)("maps %s progress presentation independently of recovery authority", async (label, remoteType) => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const definition = definitions.get(remoteType)!;
if (definition.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
throw new Error(`${label} OneShot is unavailable`);
}
const setting = Object.assign(createNewVaultSettings(), { remoteType });
const openOneShotReplicationWithOutcome = vi.fn(async () => REPLICATION_COMPLETED);
const instance = {
initializeDatabaseForReplication: vi.fn(async () => true),
openReplication: vi.fn(async () => true),
terminateSync: vi.fn(),
closeReplication: vi.fn(),
openOneShotReplicationWithOutcome,
};
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
await definition.userInitiatedOneShot.run(instance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(1, setting, false);
expect(openOneShotReplicationWithOutcome).toHaveBeenNthCalledWith(2, setting, true);
});
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
const definitions = createCentralReplicatorProviderDefinitions({} as never);
const couchDB = definitions.get(REMOTE_COUCHDB)!;
@@ -172,6 +208,7 @@ describe("central Replicator provider definitions", () => {
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
@@ -201,6 +238,7 @@ describe("central Replicator provider definitions", () => {
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
+5 -1
View File
@@ -24,7 +24,10 @@
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
import { $msg as translateMessage } from "@/common/translation";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
export let plugin: ObsidianLiveSyncPlugin;
export let core :LiveSyncBaseCore;
// $: core = plugin.core;
@@ -107,6 +110,7 @@
async function replicate() {
await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}
+5 -1
View File
@@ -4,7 +4,10 @@ import { fireAndForget } from "octagonal-wheels/promises";
import { AbstractModule } from "@/modules/AbstractModule";
import { $msg } from "@/common/translation";
import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes.
// However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version.
export class ModuleBasicMenu extends AbstractModule {
@@ -15,6 +18,7 @@ export class ModuleBasicMenu extends AbstractModule {
callback: async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
},
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { Command } from "@/deps";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleBasicMenu } from "./ModuleBasicMenu";
type RegisteredCommand = Command & {
@@ -124,6 +128,19 @@ describe("ModuleBasicMenu command palette", () => {
expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now");
});
it("keeps Sync now progress quiet while retaining failure-recovery authority", async () => {
const fixture = createFixture();
await fixture.module._everyOnloadStart();
await fixture.getCommand("livesync-replicate").callback?.();
expect(fixture.services.replication.replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
});
it("keeps maintenance commands out of the normal palette", async () => {
const fixture = createFixture();
@@ -2,7 +2,10 @@ import { addIcon } from "@/deps.ts";
import { $msg } from "@/common/translation";
import type { LiveSyncCore } from "@/main.ts";
import { AbstractModule } from "@/modules/AbstractModule.ts";
import { USER_INITIATED_REPLICATION_AUTHORITY } from "@vrtmrz/livesync-commonlib/replication";
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
// Obsidian specific menu commands.
export class ModuleObsidianMenu extends AbstractModule {
_everyOnloadStart(): Promise<boolean> {
@@ -20,6 +23,7 @@ export class ModuleObsidianMenu extends AbstractModule {
this.addRibbonIcon("replicate", $msg("moduleObsidianMenu.replicate"), async () => {
await this.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
}).addClass("livesync-ribbon-replicate");
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({ addIcon: vi.fn() }));
import {
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { ModuleObsidianMenu } from "./ModuleObsidianMenu";
describe("ModuleObsidianMenu ribbon", () => {
it("retains visible progress and full interaction authority", async () => {
let runRibbonAction: (() => Promise<void>) | undefined;
const addClass = vi.fn();
const replicateUserInitiated = vi.fn(async () => ({ status: "completed" as const }));
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
registerProtocolHandler: vi.fn(),
addRibbonIcon: vi.fn((_icon: string, _title: string, callback: () => Promise<void>) => {
runRibbonAction = callback;
return { addClass };
}),
},
replication: { replicateUserInitiated },
};
const module = new ModuleObsidianMenu({ _services: services, services } as never);
await module._everyOnloadStart();
await runRibbonAction?.();
expect(replicateUserInitiated).toHaveBeenCalledWith({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
expect(addClass).toHaveBeenCalledWith("livesync-ribbon-replicate");
});
});
@@ -5,6 +5,7 @@ import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_PROGRESS_PRESENTATIONS,
type ReplicatorInstance,
type ReplicationFailureRequest,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -52,11 +53,11 @@ function canMarkRemoteResolved(replicator: ReplicatorInstance): replicator is Re
*/
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
async function reconcileCleanedRemote(
showMessage: boolean,
showProgress: boolean,
setting: ObsidianLiveSyncSettings,
expectedContext: ReplicationFailureRequest["context"]
) {
Logger("The remote database has been cleaned.", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
await skipIfDuplicated("cleanup", async () => {
const count = await purgeUnreferencedChunks(context.getLocalDatabase().localDatabase, true);
const message = `The remote database has been cleaned up.
@@ -100,7 +101,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
localDatabase.clearCaches();
const replicated = await context.services.replicator.runFiniteReplicationActivity(
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
() => replicator.openOneShotReplication(setting, showProgress, false, "sync", true),
{ label: "replication" }
);
if (replicated) {
@@ -110,12 +111,12 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
await replicator.markRemoteResolved(setting);
Logger(
"The local database has been cleaned up.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
} else {
Logger(
"Replication has been cancelled. Please try it again.",
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
);
}
} finally {
@@ -128,14 +129,15 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
}
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
const { context: failedContext, interaction, outcome, setting, showMessage } = request;
if (!showMessage) {
const { context: failedContext, interaction, outcome, progressPresentation, setting } = request;
const showProgress = progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE;
if (interaction.kind === "forbidden") {
// Automatic requests may report the failure, but must not enter
// tweak, lock, fetch, unlock, or cleanup dialogues.
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
return false;
}
if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false;
if (!interaction.permissions.failureRecovery) return false;
const recovery = outcome.recoveryHint;
if (!recovery) return false;
@@ -169,7 +171,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
usesLegacyIndexedDBAdapter(setting)
) {
await reconcileCleanedRemote(showMessage, setting, failedContext);
await reconcileCleanedRemote(showProgress, setting, failedContext);
return false;
}
@@ -4,6 +4,7 @@ import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction }
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
NO_INTERACTION,
REPLICATION_PROGRESS_PRESENTATIONS,
USER_INITIATED_REPLICATION_AUTHORITY,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
@@ -43,7 +44,7 @@ describe("central compatibility recovery", () => {
context: { provider: {}, replicator: {} },
setting: {},
outcome: replicationFailed(new Error("provider failed")),
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: NO_INTERACTION,
} as never)
).resolves.toBe(false);
@@ -93,7 +94,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: NO_INTERACTION,
} as never);
expect(askResolvingMismatched).not.toHaveBeenCalled();
@@ -102,7 +103,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: false,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: {
kind: "permitted",
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
@@ -114,7 +115,7 @@ describe("central compatibility recovery", () => {
context: failedContext,
setting: {},
outcome,
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
@@ -158,7 +159,7 @@ describe("central compatibility recovery", () => {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
preferredTweakValue: { customChunkSize: 60 },
}),
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
@@ -196,7 +197,7 @@ describe("central compatibility recovery", () => {
outcome: replicationFailed(new Error("locked"), {
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
}),
showMessage: true,
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
} as never);
+20
View File
@@ -12,6 +12,24 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
## Unreleased
## 1.0.23
2nd September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
- **Sync now** once again keeps routine progress quiet, while still opening recovery dialogues when a decision is required. Repeated OneShot Sync requests received while an earlier attempt is running are now ignored instead of starting overlapping work.
## 1.0.22
1st September, 2026
I am sorry to make this release while several pull requests are still awaiting merge, but I believe that the safeguards provided by this work are significant, so I have decided to release it. I will merge the remaining pull requests in turn. Thank you for bearing with me while I have been less active recently.
### Synchronisation and storage
#### Fixed
@@ -26,6 +44,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
#### Fixed
- The P2P Setup connection test no longer interrupts an active P2P room. It observes an active compatible relay binding, blocks a test which would add another relay until P2P is disconnected, and uses a short-lived trial only while P2P is idle.
- User-initiated P2P synchronisation now reports success only after the requested target transfer completes.
- Optional WebApp P2P synchronisation now becomes ready after a successful local-file scan even when CouchDB remains unconfigured; failed preparation is not reported as ready.
- Unattended P2P synchronisation no longer raises Notice-level messages for missing configured targets, authentication rejection, configuration mismatch, or an overlapping transfer. User-initiated operations retain their existing feedback.
- P2P replication failure reasons now survive the JSON RPC boundary instead of reaching the requesting device as an empty object.
+3 -1
View File
@@ -33,5 +33,7 @@
"1.0.18": "1.7.2",
"1.0.19": "1.7.2",
"1.0.20": "1.7.2",
"1.0.21": "1.7.2"
"1.0.21": "1.7.2",
"1.0.22": "1.7.2",
"1.0.23": "1.7.2"
}