From 81d2e2b81517d0671fb75214a5e9d7d8dcc9a30d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 31 Jul 2026 11:28:58 +0000 Subject: [PATCH] feat: integrate adaptive journal synchronisation --- docs/design_docs/adaptive_journal_sync.md | 20 ++++--- src/apps/cli/commands/runCommand.ts | 29 +++++++++- src/apps/cli/commands/runCommand.unit.spec.ts | 56 ++++++++++++++++++- .../ObsidianLiveSyncSettingTab.ts | 4 +- .../SettingDialogue/PaneMaintenance.ts | 21 +------ .../dialogs/SetupRemotePostgREST.svelte | 24 +++++++- .../dialogs/SetupRemoteWebDAV.svelte | 32 +++++++++++ 7 files changed, 153 insertions(+), 33 deletions(-) diff --git a/docs/design_docs/adaptive_journal_sync.md b/docs/design_docs/adaptive_journal_sync.md index c882ca84..2c4f133c 100644 --- a/docs/design_docs/adaptive_journal_sync.md +++ b/docs/design_docs/adaptive_journal_sync.md @@ -1293,15 +1293,19 @@ Protocol mismatch is a repository-safety failure, not an ordinary tweak mismatch checks must not bypass it. Provider credentials may rotate without changing the format, repository identity, or checkpoint; changing provider, storage location, repository ID, or protocol creates a distinct binding. -The compatibility plan must define: +The experimental v1 policy does not negotiate or migrate between the two representations. Before an ordinary read or +write, the client classifies the remote as empty, `opaque-v1`, `adaptive-v1`, or mixed. An empty remote may initialise +the selected format. A non-empty remote must match the connection profile exactly; the client refuses a different or +mixed format before reading or writing its records. -- negotiation between old and adaptive clients; -- rollback before and after the first adaptive commit; -- coexistence or exclusion rules for mixed client versions; -- checkpoint identity and epoch changes; -- remote reset behaviour; -- encryption-key and Chunk-identity-key migration; and -- exact conditions which require Fetch, Rebuild, or a new remote profile. +Changing format therefore requires an explicit remote Rebuild. Rebuild deletes the remote representation, creates a +new repository identity and Security Seed when Adaptive is selected, clears the corresponding local Journal +checkpoints, and republishes from the local database. It does not translate the previous remote representation, and +there is no path which reads both formats. Returning to Opaque follows the same remote-only Rebuild rule. + +This policy deliberately excludes mixed-version operation. A device which does not implement the selected format must +remain disconnected until the remote has been rebuilt into a format it supports. Credential rotation remains +independent because it does not change the storage identity or selected Journal format. ### Initial provider capability matrix diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index 106f2d98..84414955 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -9,7 +9,10 @@ import { type EntryMilestoneInfo, type EntryDoc, } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { isJournalRemoteType } from "@vrtmrz/livesync-commonlib/journal-storage"; +import { + isJournalRemoteType, + journalProtocolConfigurationForSettings, +} from "@vrtmrz/livesync-commonlib/journal-storage"; import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; import { activateRemoteConfiguration, @@ -60,7 +63,29 @@ async function verifyRemoteState( } milestone = await dbRet.db.get(MILESTONE_DOCID); } else if (isJournalRemoteType(settings.remoteType)) { - milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json"); + const journal = replicator as LiveSyncJournalReplicator; + if (journalProtocolConfigurationForSettings(settings).journalFormat === "adaptive-v1") { + if (!(await journal.tryConnectRemote(settings, false))) { + standardIo.writeStderr("[Verification] Adaptive Journal connection or capabilities failed.\n"); + return false; + } + const remoteFormat = await journal.client.storage.inspectRemoteFormat?.(); + if (remoteFormat !== "adaptive-v1") { + standardIo.writeStderr( + `[Verification] Adaptive Journal repository: ${remoteFormat === "empty" ? "NOT INITIALISED" : "FORMAT MISMATCH"}\n` + ); + return false; + } + standardIo.writeStderr("[Verification] Adaptive Journal repository: READY\n"); + standardIo.writeStderr("[Verification] Legacy remote lock milestone: NOT USED\n"); + return true; + } + const client = journal.client; + if (!("downloadJson" in client) || typeof client.downloadJson !== "function") { + standardIo.writeStderr("[Verification] Journal data format changed during verification.\n"); + return false; + } + milestone = await client.downloadJson("_00000000-milestone.json"); } if (milestone) { diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index 84b7d7fe..c0fd5bbc 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -748,7 +748,27 @@ describe("runCommand abnormal cases", () => { locked: false, accepted_nodes: ["test-node-id"], })); - core.services.setting.currentSettings().remoteType = remoteType; + const settings = core.services.setting.currentSettings(); + settings.remoteType = remoteType; + if (remoteType === REMOTE_WEBDAV) { + settings.webDAVactiveConnectionURI = serialiseWebDAVConnectionURI({ + customHeaders: "", + endpoint: "https://dav.example/vault", + password: "webdav-pass", + prefix: "journal/", + useCustomRequestHandler: false, + username: "webdav-user", + }); + } else { + settings.postgrestActiveConnectionURI = serialisePostgRESTConnectionURI({ + bearerToken: "signed-token", + customHeaders: "", + endpoint: "https://journal.example", + schema: "livesync_api", + useCustomRequestHandler: false, + vaultId: "vault-1", + }); + } core.services.replicator.getActiveReplicator.mockReturnValue({ nodeid: "test-node-id", initializeDatabaseForReplication: vi.fn(async () => {}), @@ -765,6 +785,40 @@ describe("runCommand abnormal cases", () => { } ); + it("verifies an Adaptive Journal repository without a legacy milestone", async () => { + const core = createCoreMock(); + const settings = core.services.setting.currentSettings(); + settings.remoteType = REMOTE_WEBDAV; + settings.webDAVactiveConnectionURI = serialiseWebDAVConnectionURI({ + customHeaders: "", + endpoint: "https://dav.example/vault", + journalFormat: "adaptive-v1", + password: "webdav-pass", + prefix: "journal/", + useCustomRequestHandler: false, + username: "webdav-user", + }); + const inspectRemoteFormat = vi.fn(async () => "adaptive-v1" as const); + const tryConnectRemote = vi.fn(async () => true); + core.services.replicator.getActiveReplicator.mockReturnValue({ + client: { + storage: { inspectRemoteFormat }, + }, + initializeDatabaseForReplication: vi.fn(async () => {}), + nodeid: "test-node-id", + tryConnectRemote, + }); + + const result = await runCommand(makeOptions("mark-resolved", []), { + ...context, + core, + }); + + expect(result).toBe(true); + expect(tryConnectRemote).toHaveBeenCalledWith(settings, false); + expect(inspectRemoteFormat).toHaveBeenCalledOnce(); + }); + it("mark-resolved without args runs on active database", async () => { const core = createCoreMock(); const result = await runCommand(makeOptions("mark-resolved", []), { diff --git a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts index 5b3b7e00..ff4b4994 100644 --- a/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts +++ b/src/modules/features/SettingDialogue/ObsidianLiveSyncSettingTab.ts @@ -838,6 +838,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { return this.core.replicator.client; } async resetRemoteBucket() { - await this.getJournalSyncClient().resetBucket(); + if (!(await this.getJournalSyncClient().resetBucket())) { + throw new Error("Remote Journal storage reset did not complete"); + } } } diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index 3f59f2c3..b2f6bbf6 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -155,11 +155,7 @@ export function paneMaintenance( .setWarning() .setDisabled(false) .onClick(async () => { - await this.getJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - receivedFiles: new Set(), - knownIDs: new Set(), - })); + await this.getJournalSyncClient().resetReceivedHistory(); Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE); }) ) @@ -176,12 +172,7 @@ export function paneMaintenance( .setWarning() .setDisabled(false) .onClick(async () => { - await this.getJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - lastLocalSeq: 0, - sentIDs: new Set(), - sentFiles: new Set(), - })); + await this.getJournalSyncClient().resetSentHistory(); Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE); }) ) @@ -363,14 +354,6 @@ export function paneMaintenance( .setWarning() .setDisabled(false) .onClick(async () => { - await this.getJournalSyncClient().updateCheckPointInfo((info) => ({ - ...info, - receivedFiles: new Set(), - knownIDs: new Set(), - lastLocalSeq: 0, - sentIDs: new Set(), - sentFiles: new Set(), - })); await this.resetRemoteBucket(); Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE); }) diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte index a01b5ce0..d0826e39 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte @@ -26,6 +26,9 @@ schema: "livesync_api", useCustomRequestHandler: false, customHeaders: "", + journalFormat: "opaque-v1", + expectedRepositoryId: "", + packReadPolicy: "whole-pack", }); type Props = GuestDialogProps; @@ -46,12 +49,17 @@ let processing = $state(false); function normalisedConnection(): PostgRESTConnection { + const journalFormat = connection.journalFormat ?? "opaque-v1"; return { ...connection, endpoint: connection.endpoint.trim(), bearerToken: connection.bearerToken.trim(), vaultId: connection.vaultId.trim(), schema: connection.schema.trim(), + journalFormat, + expectedRepositoryId: + journalFormat === "adaptive-v1" ? (connection.expectedRepositoryId ?? "").trim() : "", + packReadPolicy: "whole-pack", }; } @@ -69,6 +77,7 @@ } const canProceed = $derived.by(isConnectionValid); + const isAdaptive = $derived(connection.journalFormat === "adaptive-v1"); const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://")); const hasInvalidInput = $derived.by( () => @@ -124,8 +133,9 @@ - Configure the LiveSync Journal RPC schema exposed by PostgREST. This is a Journal object transport, not a CouchDB - replacement or direct table editor. + Configure the LiveSync Journal RPC schema exposed by PostgREST. Opaque Journal stores complete Journal objects; + Adaptive Journal uses native immutable Metadata, Chunk, and Commit records. Neither mode is a CouchDB replacement + or direct table editor. @@ -194,6 +204,16 @@ + + + + + Adaptive Journal requires `002_adaptive_journal.sql` and uses a different remote data format. Existing Opaque + data is not migrated or read; rebuild the remote when changing formats. +