feat: integrate adaptive journal synchronisation

This commit is contained in:
vorotamoroz
2026-07-31 11:28:58 +00:00
parent 0ad71ccecb
commit 81d2e2b815
7 changed files with 153 additions and 33 deletions
+12 -8
View File
@@ -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 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. 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; Changing format therefore requires an explicit remote Rebuild. Rebuild deletes the remote representation, creates a
- rollback before and after the first adaptive commit; new repository identity and Security Seed when Adaptive is selected, clears the corresponding local Journal
- coexistence or exclusion rules for mixed client versions; checkpoints, and republishes from the local database. It does not translate the previous remote representation, and
- checkpoint identity and epoch changes; there is no path which reads both formats. Returning to Opaque follows the same remote-only Rebuild rule.
- remote reset behaviour;
- encryption-key and Chunk-identity-key migration; and This policy deliberately excludes mixed-version operation. A device which does not implement the selected format must
- exact conditions which require Fetch, Rebuild, or a new remote profile. 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 ### Initial provider capability matrix
+27 -2
View File
@@ -9,7 +9,10 @@ import {
type EntryMilestoneInfo, type EntryMilestoneInfo,
type EntryDoc, type EntryDoc,
} from "@vrtmrz/livesync-commonlib/compat/common/types"; } 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 { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { import {
activateRemoteConfiguration, activateRemoteConfiguration,
@@ -60,7 +63,29 @@ async function verifyRemoteState(
} }
milestone = await dbRet.db.get(MILESTONE_DOCID); milestone = await dbRet.db.get(MILESTONE_DOCID);
} else if (isJournalRemoteType(settings.remoteType)) { } 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) { if (milestone) {
+55 -1
View File
@@ -748,7 +748,27 @@ describe("runCommand abnormal cases", () => {
locked: false, locked: false,
accepted_nodes: ["test-node-id"], 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({ core.services.replicator.getActiveReplicator.mockReturnValue({
nodeid: "test-node-id", nodeid: "test-node-id",
initializeDatabaseForReplication: vi.fn(async () => {}), 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 () => { it("mark-resolved without args runs on active database", async () => {
const core = createCoreMock(); const core = createCoreMock();
const result = await runCommand(makeOptions("mark-resolved", []), { const result = await runCommand(makeOptions("mark-resolved", []), {
@@ -838,6 +838,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
return this.core.replicator.client; return this.core.replicator.client;
} }
async resetRemoteBucket() { async resetRemoteBucket() {
await this.getJournalSyncClient().resetBucket(); if (!(await this.getJournalSyncClient().resetBucket())) {
throw new Error("Remote Journal storage reset did not complete");
}
} }
} }
@@ -155,11 +155,7 @@ export function paneMaintenance(
.setWarning() .setWarning()
.setDisabled(false) .setDisabled(false)
.onClick(async () => { .onClick(async () => {
await this.getJournalSyncClient().updateCheckPointInfo((info) => ({ await this.getJournalSyncClient().resetReceivedHistory();
...info,
receivedFiles: new Set(),
knownIDs: new Set(),
}));
Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE); Logger(`Journal received history has been cleared.`, LOG_LEVEL_NOTICE);
}) })
) )
@@ -176,12 +172,7 @@ export function paneMaintenance(
.setWarning() .setWarning()
.setDisabled(false) .setDisabled(false)
.onClick(async () => { .onClick(async () => {
await this.getJournalSyncClient().updateCheckPointInfo((info) => ({ await this.getJournalSyncClient().resetSentHistory();
...info,
lastLocalSeq: 0,
sentIDs: new Set(),
sentFiles: new Set(),
}));
Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE); Logger(`Journal sent history has been cleared.`, LOG_LEVEL_NOTICE);
}) })
) )
@@ -363,14 +354,6 @@ export function paneMaintenance(
.setWarning() .setWarning()
.setDisabled(false) .setDisabled(false)
.onClick(async () => { .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(); await this.resetRemoteBucket();
Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE); Logger(`Deleted all data on remote server`, LOG_LEVEL_NOTICE);
}) })
@@ -26,6 +26,9 @@
schema: "livesync_api", schema: "livesync_api",
useCustomRequestHandler: false, useCustomRequestHandler: false,
customHeaders: "", customHeaders: "",
journalFormat: "opaque-v1",
expectedRepositoryId: "",
packReadPolicy: "whole-pack",
}); });
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, PostgRESTSyncSetting>; type Props = GuestDialogProps<SetupRemotePostgRESTResultType, PostgRESTSyncSetting>;
@@ -46,12 +49,17 @@
let processing = $state(false); let processing = $state(false);
function normalisedConnection(): PostgRESTConnection { function normalisedConnection(): PostgRESTConnection {
const journalFormat = connection.journalFormat ?? "opaque-v1";
return { return {
...connection, ...connection,
endpoint: connection.endpoint.trim(), endpoint: connection.endpoint.trim(),
bearerToken: connection.bearerToken.trim(), bearerToken: connection.bearerToken.trim(),
vaultId: connection.vaultId.trim(), vaultId: connection.vaultId.trim(),
schema: connection.schema.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 canProceed = $derived.by(isConnectionValid);
const isAdaptive = $derived(connection.journalFormat === "adaptive-v1");
const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://")); const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://"));
const hasInvalidInput = $derived.by( const hasInvalidInput = $derived.by(
() => () =>
@@ -124,8 +133,9 @@
<DialogHeader title="PostgREST Journal Configuration" /> <DialogHeader title="PostgREST Journal Configuration" />
<Guidance> <Guidance>
Configure the LiveSync Journal RPC schema exposed by PostgREST. This is a Journal object transport, not a CouchDB Configure the LiveSync Journal RPC schema exposed by PostgREST. Opaque Journal stores complete Journal objects;
replacement or direct table editor. Adaptive Journal uses native immutable Metadata, Chunk, and Commit records. Neither mode is a CouchDB replacement
or direct table editor.
</Guidance> </Guidance>
<InputRow label="PostgREST Endpoint URL"> <InputRow label="PostgREST Endpoint URL">
@@ -194,6 +204,16 @@
</InfoNote> </InfoNote>
<ExtraItems title="Advanced Settings"> <ExtraItems title="Advanced Settings">
<InputRow label="Journal Data Format">
<select name="postgrest-journal-format" bind:value={connection.journalFormat}>
<option value="opaque-v1">Opaque Journal (current format)</option>
<option value="adaptive-v1">Adaptive Journal (experimental)</option>
</select>
</InputRow>
<InfoNote warning visible={isAdaptive}>
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.
</InfoNote>
<InputRow label="Custom Headers"> <InputRow label="Custom Headers">
<textarea <textarea
name="postgrest-custom-headers" name="postgrest-custom-headers"
@@ -26,6 +26,9 @@
prefix: "", prefix: "",
useCustomRequestHandler: false, useCustomRequestHandler: false,
customHeaders: "", customHeaders: "",
journalFormat: "opaque-v1",
expectedRepositoryId: "",
packReadPolicy: "whole-pack",
}); });
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>; type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>;
@@ -46,11 +49,17 @@
let processing = $state(false); let processing = $state(false);
function normalisedConnection(): WebDAVConnection { function normalisedConnection(): WebDAVConnection {
const journalFormat = connection.journalFormat ?? "opaque-v1";
return { return {
...connection, ...connection,
endpoint: connection.endpoint.trim(), endpoint: connection.endpoint.trim(),
prefix: connection.prefix.trim(), prefix: connection.prefix.trim(),
username: connection.username.trim(), username: connection.username.trim(),
journalFormat,
expectedRepositoryId:
journalFormat === "adaptive-v1" ? (connection.expectedRepositoryId ?? "").trim() : "",
packReadPolicy:
journalFormat === "adaptive-v1" ? (connection.packReadPolicy ?? "whole-pack") : "whole-pack",
}; };
} }
@@ -64,6 +73,7 @@
} }
const canProceed = $derived.by(isConnectionValid); const canProceed = $derived.by(isConnectionValid);
const isAdaptive = $derived(connection.journalFormat === "adaptive-v1");
const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://")); const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointInvalid = $derived.by(() => connection.endpoint.trim() !== "" && !canProceed); const isEndpointInvalid = $derived.by(() => connection.endpoint.trim() !== "" && !canProceed);
@@ -174,6 +184,28 @@
</InfoNote> </InfoNote>
<ExtraItems title="Advanced Settings"> <ExtraItems title="Advanced Settings">
<InputRow label="Journal Data Format">
<select name="webdav-journal-format" bind:value={connection.journalFormat}>
<option value="opaque-v1">Opaque Journal (current format)</option>
<option value="adaptive-v1">Adaptive Journal (experimental)</option>
</select>
</InputRow>
<InfoNote warning visible={isAdaptive}>
Adaptive Journal uses a different remote data format. Existing Opaque data is not migrated or read; rebuild the
remote when changing formats.
</InfoNote>
{#if isAdaptive}
<InputRow label="Pack Retrieval">
<select name="webdav-pack-read-policy" bind:value={connection.packReadPolicy}>
<option value="whole-pack">Download complete packs</option>
<option value="range">Use HTTP Range requests</option>
</select>
</InputRow>
<InfoNote>
Complete-pack reads favour throughput. Range reads can reduce transferred bytes, but require correct HTTP
byte-range support; the connection test checks that capability when selected.
</InfoNote>
{/if}
<InputRow label="Custom Headers"> <InputRow label="Custom Headers">
<textarea <textarea
name="webdav-custom-headers" name="webdav-custom-headers"