mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-01 08:17:07 +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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user