mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-26 13:27:05 +00:00
feat: integrate adaptive journal synchronisation
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user