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
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
+27 -2
View File
@@ -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) {
+55 -1
View File
@@ -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", []), {
@@ -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");
}
}
}
@@ -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);
})
@@ -26,6 +26,9 @@
schema: "livesync_api",
useCustomRequestHandler: false,
customHeaders: "",
journalFormat: "opaque-v1",
expectedRepositoryId: "",
packReadPolicy: "whole-pack",
});
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, PostgRESTSyncSetting>;
@@ -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 @@
<DialogHeader title="PostgREST Journal Configuration" />
<Guidance>
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.
</Guidance>
<InputRow label="PostgREST Endpoint URL">
@@ -194,6 +204,16 @@
</InfoNote>
<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">
<textarea
name="postgrest-custom-headers"
@@ -26,6 +26,9 @@
prefix: "",
useCustomRequestHandler: false,
customHeaders: "",
journalFormat: "opaque-v1",
expectedRepositoryId: "",
packReadPolicy: "whole-pack",
});
type Props = GuestDialogProps<SetupRemoteWebDAVResultType, WebDAVSyncSetting>;
@@ -46,11 +49,17 @@
let processing = $state(false);
function normalisedConnection(): WebDAVConnection {
const journalFormat = connection.journalFormat ?? "opaque-v1";
return {
...connection,
endpoint: connection.endpoint.trim(),
prefix: connection.prefix.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 isAdaptive = $derived(connection.journalFormat === "adaptive-v1");
const isEndpointInsecure = $derived.by(() => connection.endpoint.trim().toLowerCase().startsWith("http://"));
const isEndpointInvalid = $derived.by(() => connection.endpoint.trim() !== "" && !canProceed);
@@ -174,6 +184,28 @@
</InfoNote>
<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">
<textarea
name="webdav-custom-headers"