mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-26 13:27:05 +00:00
feat: configure Adaptive PostgREST journal remotes
This commit is contained in:
@@ -46,6 +46,16 @@ function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): strin
|
||||
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
|
||||
return defaultRemoteProviderRegistry.serialise(configuration);
|
||||
}
|
||||
|
||||
function describeRemoteConfiguration(uri: string): string {
|
||||
try {
|
||||
const configuration = defaultRemoteProviderRegistry.parse(uri);
|
||||
return defaultRemoteProviderRegistry.suggestName(configuration);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
|
||||
button.setButtonText(emoji);
|
||||
button.setTooltip(tooltip, { delay: 10, placement: "top" });
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
pickBucketSyncSettings,
|
||||
pickCouchDBSyncSettings,
|
||||
pickP2PSyncSettings,
|
||||
pickPostgRESTSyncSettings,
|
||||
pickWebDAVSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -17,6 +18,7 @@ export function syncActivatedRemoteSettings(
|
||||
activeConfigurationId: source.activeConfigurationId,
|
||||
...pickBucketSyncSettings(source),
|
||||
...pickWebDAVSyncSettings(source),
|
||||
...pickPostgRESTSyncSettings(source),
|
||||
...pickCouchDBSyncSettings(source),
|
||||
...pickP2PSyncSettings(source),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_POSTGREST,
|
||||
REMOTE_WEBDAV,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer";
|
||||
@@ -121,4 +122,35 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
expect(target.journalFormat).toBe("adaptive-v1");
|
||||
expect(target.packReadPolicy).toBe("range");
|
||||
});
|
||||
|
||||
it("should copy the active PostgREST connection and fixed Adaptive protocol into the editing buffer", () => {
|
||||
const target = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
activeConfigurationId: "old-remote",
|
||||
postgrestActiveConnectionURI: "",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
};
|
||||
const source = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_POSTGREST,
|
||||
activeConfigurationId: "remote-postgrest",
|
||||
postgrestActiveConnectionURI:
|
||||
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "whole-pack" as const,
|
||||
};
|
||||
|
||||
syncActivatedRemoteSettings(target, source);
|
||||
|
||||
expect(target.remoteType).toBe(REMOTE_POSTGREST);
|
||||
expect(target.activeConfigurationId).toBe("remote-postgrest");
|
||||
expect(target.postgrestActiveConnectionURI).toBe(source.postgrestActiveConnectionURI);
|
||||
expect(target.expectedRepositoryId).toBe(source.expectedRepositoryId);
|
||||
expect(target.journalFormat).toBe("adaptive-v1");
|
||||
expect(target.packReadPolicy).toBe("whole-pack");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,6 +299,21 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onRemoteManualSetup("webdav", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for Adaptive Journal storage through PostgREST.
|
||||
* @param userMode
|
||||
* @param currentSetting
|
||||
* @param activate Whether to activate PostgREST as the main remote type
|
||||
* @returns Promise that resolves to true if setup completed successfully, false otherwise
|
||||
*/
|
||||
async onPostgRESTManualSetup(
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
return await this.onRemoteManualSetup("postgrest", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for P2P
|
||||
* @param userMode
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_POSTGREST,
|
||||
REMOTE_WEBDAV,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -24,6 +25,7 @@ vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteWebDAV.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemotePostgREST.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
|
||||
@@ -653,6 +655,87 @@ describe("SetupManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds and activates a manually configured PostgREST profile without replacing existing profiles", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
...setting.currentSettings(),
|
||||
isConfigured: true,
|
||||
remoteConfigurations: {
|
||||
existing: {
|
||||
id: "existing",
|
||||
name: "Existing remote",
|
||||
uri: "sls+http://old:secret@old.example/?db=old",
|
||||
isEncrypted: false,
|
||||
},
|
||||
},
|
||||
activeConfigurationId: "existing",
|
||||
};
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
postgrestActiveConnectionURI:
|
||||
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onPostgRESTManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.remoteType).toBe(REMOTE_POSTGREST);
|
||||
expect(current.remoteConfigurations.existing).toBeDefined();
|
||||
expect(Object.keys(current.remoteConfigurations)).toHaveLength(2);
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("PostgREST project.example");
|
||||
expect(activeProfile?.uri).toContain("sls+postgrest://vault-id-00000001:vault-credential@project.example");
|
||||
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
|
||||
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
expect(ConnectionStringParser.parse(activeProfile?.uri ?? "")).toMatchObject({
|
||||
type: "postgrest",
|
||||
settings: { packReadPolicy: "whole-pack" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[UserMode.NewUser, "onboarding"],
|
||||
[UserMode.ExistingUser, "onboarding"],
|
||||
[UserMode.Update, "settings"],
|
||||
] as const)("passes the %s PostgREST verification policy to the manual setup dialogue", async (userMode, mode) => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
||||
vi.spyOn(manager, "onOnboard").mockResolvedValue(false);
|
||||
|
||||
await manager.onPostgRESTManualSetup(userMode, setting.currentSettings());
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
|
||||
settings: setting.currentSettings(),
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes a manual PostgREST selection through the registered setup provider", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce("postgrest")
|
||||
.mockResolvedValueOnce({
|
||||
postgrestActiveConnectionURI:
|
||||
"sls+postgrest://vault-id-00000001:vault-credential@project.example/rest/v1?apiKey=publishable",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onSelectServer(setting.currentSettings(), UserMode.NewUser);
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(2, expect.anything(), {
|
||||
settings: expect.anything(),
|
||||
mode: "onboarding",
|
||||
});
|
||||
expect(setting.currentSettings().remoteType).toBe(REMOTE_POSTGREST);
|
||||
});
|
||||
|
||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
setting.settings = {
|
||||
|
||||
@@ -5,12 +5,15 @@ import { $msg as translateMessage } from "@/common/translation";
|
||||
import SetupRemoteBucket from "./dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteCouchDB from "./dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteP2P from "./dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemotePostgREST from "./dialogs/SetupRemotePostgREST.svelte";
|
||||
import SetupRemoteWebDAV from "./dialogs/SetupRemoteWebDAV.svelte";
|
||||
import type {
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemotePostgRESTInitialData,
|
||||
SetupRemotePostgRESTResultType,
|
||||
SetupRemoteWebDAVInitialData,
|
||||
SetupRemoteWebDAVResultType,
|
||||
} from "./dialogs/setupDialogTypes";
|
||||
@@ -121,11 +124,39 @@ export function useWebDAVRemoteSetup(
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function usePostgRESTRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"postgrest">> = {
|
||||
type: "postgrest",
|
||||
choice: () => ({
|
||||
title: translateMessage("PostgREST Journal"),
|
||||
description: translateMessage(
|
||||
"Store Adaptive Journal records through the packaged PostgREST SQL contract. This experimental provider requires a provisioned Vault credential, and onboarding requires a successful server capability check."
|
||||
),
|
||||
proceedTitle: translateMessage("Continue to PostgREST setup"),
|
||||
}),
|
||||
open: async ({ dialogManager, intent, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemotePostgRESTResultType,
|
||||
SetupRemotePostgRESTInitialData
|
||||
>(SetupRemotePostgREST, {
|
||||
settings,
|
||||
mode: intent === "settings" ? "settings" : "onboarding",
|
||||
});
|
||||
return result === "cancelled" ? result : { type: "postgrest", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||
useCouchDBRemoteSetup(registry);
|
||||
useS3RemoteSetup(registry);
|
||||
useWebDAVRemoteSetup(registry);
|
||||
usePostgRESTRemoteSetup(registry);
|
||||
useP2PRemoteSetup(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
|
||||
import Guidance from "@/modules/services/LiveSyncUI/components/Guidance.svelte";
|
||||
import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import InfoNote from "@/modules/services/LiveSyncUI/components/InfoNote.svelte";
|
||||
import ExtraItems from "@/modules/services/LiveSyncUI/components/ExtraItems.svelte";
|
||||
import InputRow from "@/modules/services/LiveSyncUI/components/InputRow.svelte";
|
||||
import Password from "@/modules/services/LiveSyncUI/components/Password.svelte";
|
||||
import { getDialogContext, type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
PREFERRED_JOURNAL_SYNC,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REMOTE_POSTGREST,
|
||||
isJournalStorageConnectionInspector,
|
||||
type JournalStorageConnectivityResult,
|
||||
type PostgRESTSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import {
|
||||
TYPE_CANCELLED,
|
||||
type PostgRESTSetupMode,
|
||||
type SetupRemotePostgRESTInitialData,
|
||||
type SetupRemotePostgRESTResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import {
|
||||
postgRESTJournalFormFromSettings,
|
||||
postgRESTSyncSettingsFromForm,
|
||||
type PostgRESTJournalForm,
|
||||
} from "./postgRESTJournalSettings";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
|
||||
let syncSetting = $state<PostgRESTJournalForm>(
|
||||
postgRESTJournalFormFromSettings({
|
||||
postgrestActiveConnectionURI: "",
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
);
|
||||
let setupMode = $state<PostgRESTSetupMode>("settings");
|
||||
let error = $state("");
|
||||
let processing = $state(false);
|
||||
let inspection = $state<JournalStorageConnectivityResult | undefined>();
|
||||
let inspectionFingerprint = $state("");
|
||||
let inspectedSettings = $state<PostgRESTSyncSetting | undefined>();
|
||||
|
||||
type Props = GuestDialogProps<SetupRemotePostgRESTResultType, SetupRemotePostgRESTInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
const context = getDialogContext();
|
||||
|
||||
onMount(() => {
|
||||
const initialData = getInitialData?.();
|
||||
if (!initialData) return;
|
||||
setupMode = initialData.mode;
|
||||
try {
|
||||
Object.assign(syncSetting, postgRESTJournalFormFromSettings(initialData.settings));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const isEndpointInsecure = $derived.by(() => syncSetting.endpoint.trim().toLowerCase().startsWith("http://"));
|
||||
const isEndpointValid = $derived.by(() => {
|
||||
try {
|
||||
const endpoint = new URL(syncSetting.endpoint.trim());
|
||||
return (
|
||||
(endpoint.protocol === "http:" || endpoint.protocol === "https:") &&
|
||||
endpoint.username === "" &&
|
||||
endpoint.password === "" &&
|
||||
endpoint.search === "" &&
|
||||
endpoint.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const isSchemaValid = $derived(/^[A-Za-z_][A-Za-z0-9_]*$/u.test(syncSetting.schema.trim()));
|
||||
const isVaultIdValid = $derived(/^[A-Za-z0-9_-]{16,128}$/u.test(syncSetting.vaultId.trim()));
|
||||
const isVaultCredentialValid = $derived(
|
||||
syncSetting.vaultCredential.length > 0 && new TextEncoder().encode(syncSetting.vaultCredential).byteLength <= 512
|
||||
);
|
||||
const isConnectionValid = $derived(
|
||||
isEndpointValid && isSchemaValid && isVaultIdValid && isVaultCredentialValid
|
||||
);
|
||||
const hasInvalidInput = $derived.by(
|
||||
() =>
|
||||
(syncSetting.endpoint.trim() !== "" ||
|
||||
syncSetting.vaultId.trim() !== "" ||
|
||||
syncSetting.vaultCredential !== "") &&
|
||||
!isConnectionValid
|
||||
);
|
||||
const formFingerprint = $derived.by(() => JSON.stringify(syncSetting));
|
||||
const inspectionIsCurrent = $derived(
|
||||
inspection !== undefined && inspectionFingerprint !== "" && inspectionFingerprint === formFingerprint
|
||||
);
|
||||
const requiredCapability = $derived.by(() => {
|
||||
if (!inspectionIsCurrent) return undefined;
|
||||
return inspection?.adaptiveCapabilities?.required;
|
||||
});
|
||||
|
||||
function generateSetting(postgRESTSettings: PostgRESTSyncSetting): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...PREFERRED_JOURNAL_SYNC,
|
||||
remoteType: REMOTE_POSTGREST,
|
||||
...postgRESTSettings,
|
||||
};
|
||||
}
|
||||
|
||||
function explainUnavailable(result: JournalStorageConnectivityResult): string {
|
||||
if (result.remoteFormat !== undefined && result.remoteFormat !== "empty" && result.remoteFormat !== "adaptive-v1") {
|
||||
return translateMessage(
|
||||
"The remote contains ${REMOTE_FORMAT} data, but this profile selects ${SELECTED_FORMAT}. Rebuild the remote or restore the matching format.",
|
||||
{ REMOTE_FORMAT: result.remoteFormat, SELECTED_FORMAT: "adaptive-v1" }
|
||||
);
|
||||
}
|
||||
const required = result.adaptiveCapabilities?.required;
|
||||
if (required?.status === "unsupported") {
|
||||
return translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
|
||||
CAPABILITIES: required.missing.join(", "),
|
||||
});
|
||||
}
|
||||
if (required?.status === "failed") {
|
||||
return translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: required.failure.category,
|
||||
RETRY: required.failure.retry,
|
||||
});
|
||||
}
|
||||
return translateMessage("The PostgREST SQL contract is unavailable or incompatible with this build.");
|
||||
}
|
||||
|
||||
async function inspectConnection(trialRemoteSetting: ObsidianLiveSyncSettings, testedFingerprint: string) {
|
||||
const replicator = await context.services.replicator.getNewReplicator(trialRemoteSetting);
|
||||
if (!replicator) {
|
||||
throw new Error(translateMessage("Failed to create replicator instance."));
|
||||
}
|
||||
if (!isJournalStorageConnectionInspector(replicator)) {
|
||||
throw new Error(translateMessage("This build cannot inspect PostgREST Journal capabilities."));
|
||||
}
|
||||
const result = await replicator.inspectJournalStorageConnection(trialRemoteSetting);
|
||||
inspection = result;
|
||||
inspectionFingerprint = testedFingerprint;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function checkConnection() {
|
||||
error = "";
|
||||
inspection = undefined;
|
||||
inspectionFingerprint = "";
|
||||
inspectedSettings = undefined;
|
||||
processing = true;
|
||||
try {
|
||||
const testedFingerprint = formFingerprint;
|
||||
const candidate = postgRESTSyncSettingsFromForm(syncSetting);
|
||||
const trialRemoteSetting = generateSetting(candidate);
|
||||
const result = await inspectConnection(trialRemoteSetting, testedFingerprint);
|
||||
if (testedFingerprint !== formFingerprint) return;
|
||||
if (!result.available) {
|
||||
error = explainUnavailable(result);
|
||||
return;
|
||||
}
|
||||
inspectedSettings = candidate;
|
||||
} catch (ex) {
|
||||
error = translateMessage("Error during connection test: ${reason}", {
|
||||
reason: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
} finally {
|
||||
processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function commitVerified() {
|
||||
if (!inspectionIsCurrent || !inspection?.available || !inspectedSettings) return;
|
||||
setResult(inspectedSettings);
|
||||
}
|
||||
|
||||
function commit() {
|
||||
error = "";
|
||||
if (!isConnectionValid) return;
|
||||
try {
|
||||
setResult(postgRESTSyncSettingsFromForm(syncSetting));
|
||||
} catch (ex) {
|
||||
error = translateMessage("Invalid PostgREST settings: ${REASON}", {
|
||||
REASON: ex instanceof Error ? ex.message : `${ex}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("PostgREST Journal Configuration")} />
|
||||
<Guidance>
|
||||
{translateMessage(
|
||||
"Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly."
|
||||
)}
|
||||
</Guidance>
|
||||
|
||||
<InputRow label={translateMessage("Endpoint URL")}>
|
||||
<input
|
||||
type="text"
|
||||
name="postgrest-endpoint"
|
||||
placeholder="https://project.example/rest/v1"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
pattern="^https?://.+"
|
||||
bind:value={syncSetting.endpoint}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isEndpointInsecure}>
|
||||
{translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={syncSetting.endpoint.trim() !== "" && !isEndpointValid}>
|
||||
{translateMessage(
|
||||
"Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Vault ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="postgrest-vault-id"
|
||||
placeholder="provisioned-vault-id"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.vaultId}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Vault credential")}>
|
||||
<Password
|
||||
name="postgrest-vault-credential"
|
||||
placeholder={translateMessage("Enter the provisioned Vault credential")}
|
||||
required
|
||||
bind:value={syncSetting.vaultCredential}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Exposed schema")}>
|
||||
<input
|
||||
type="text"
|
||||
name="postgrest-schema"
|
||||
placeholder="livesync_api"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
bind:value={syncSetting.schema}
|
||||
/>
|
||||
</InputRow>
|
||||
<InputRow label={translateMessage("Client API key (optional)")}>
|
||||
<Password
|
||||
name="postgrest-api-key"
|
||||
placeholder={translateMessage("Supabase publishable key, if required")}
|
||||
bind:value={syncSetting.apiKey}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote caution>
|
||||
{translateMessage(
|
||||
"Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={hasInvalidInput}>
|
||||
{translateMessage(
|
||||
"Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<InputRow label={translateMessage("Use internal API")}>
|
||||
<input type="checkbox" name="postgrest-use-internal-api" bind:checked={syncSetting.useCustomRequestHandler} />
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Enable this when browser-compatible requests are blocked by CORS. It uses Obsidian's internal request API and may behave differently from standard browser fetch."
|
||||
)}
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Expected repository ID")}>
|
||||
<input
|
||||
type="text"
|
||||
name="postgrest-expected-repository-id"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
bind:value={syncSetting.expectedRepositoryId}
|
||||
/>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"This optional identity pins a trusted Adaptive repository. A Setup URI can supply it; leave it blank only when creating a repository or intentionally trusting the first compatible repository reached."
|
||||
)}
|
||||
</InfoNote>
|
||||
</ExtraItems>
|
||||
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"PostgREST stores only Adaptive Journal records. It cannot read Opaque Journal data, and format changes require a remote Rebuild rather than an in-place migration."
|
||||
)}
|
||||
</InfoNote>
|
||||
{#if requiredCapability?.status === "verified"}
|
||||
<InfoNote notice>
|
||||
{translateMessage("The required PostgREST RPC operations and binary semantics were verified.")}
|
||||
</InfoNote>
|
||||
{:else if requiredCapability?.status === "unsupported"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", {
|
||||
CAPABILITIES: requiredCapability.missing.join(", "),
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else if requiredCapability?.status === "failed"}
|
||||
<InfoNote error>
|
||||
{translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", {
|
||||
CATEGORY: requiredCapability.failure.category,
|
||||
RETRY: requiredCapability.failure.retry,
|
||||
})}
|
||||
</InfoNote>
|
||||
{:else if inspectionIsCurrent}
|
||||
<InfoNote warning>{translateMessage("Required Adaptive operations were not checked.")}</InfoNote>
|
||||
{/if}
|
||||
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"The saved connection contains the Vault credential and optional API key. Configuration encryption protects exported Setup data when it is enabled; do not share a plain connection string."
|
||||
)}
|
||||
</InfoNote>
|
||||
<InfoNote error visible={error !== ""}>{error}</InfoNote>
|
||||
|
||||
{#if processing}
|
||||
{translateMessage("Checking connection... Please wait.")}
|
||||
{:else}
|
||||
<UserDecisions>
|
||||
{#if inspectionIsCurrent && inspection?.available && inspectedSettings}
|
||||
<Decision
|
||||
title={setupMode === "settings"
|
||||
? translateMessage("Save verified settings")
|
||||
: translateMessage("Continue with verified settings")}
|
||||
important
|
||||
commit={() => commitVerified()}
|
||||
/>
|
||||
<Decision
|
||||
title={translateMessage("Check PostgREST server")}
|
||||
disabled={!isConnectionValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{:else}
|
||||
<Decision
|
||||
title={translateMessage("Check PostgREST server")}
|
||||
important
|
||||
disabled={!isConnectionValid}
|
||||
commit={() => checkConnection()}
|
||||
/>
|
||||
{/if}
|
||||
{#if setupMode === "settings"}
|
||||
<InfoNote warning>
|
||||
{translateMessage(
|
||||
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected."
|
||||
)}
|
||||
</InfoNote>
|
||||
<Decision
|
||||
title={translateMessage("Save without connecting")}
|
||||
disabled={!isConnectionValid}
|
||||
commit={() => commit()}
|
||||
/>
|
||||
{/if}
|
||||
<Decision title={translateMessage("Cancel")} commit={() => setResult(TYPE_CANCELLED)} />
|
||||
</UserDecisions>
|
||||
{/if}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
REMOTE_POSTGREST,
|
||||
journalProtocolConfigurationForSettings,
|
||||
parsePostgRESTConnectionURI,
|
||||
serialisePostgRESTConnectionURI,
|
||||
type PostgRESTConnection,
|
||||
type PostgRESTSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
|
||||
export type PostgRESTJournalForm = PostgRESTConnection & {
|
||||
expectedRepositoryId: string;
|
||||
};
|
||||
|
||||
const emptyPostgRESTConnection: PostgRESTConnection = {
|
||||
apiKey: "",
|
||||
endpoint: "",
|
||||
schema: "livesync_api",
|
||||
useCustomRequestHandler: false,
|
||||
vaultCredential: "",
|
||||
vaultId: "",
|
||||
};
|
||||
|
||||
function resolveProtocol(settings: PostgRESTSyncSetting) {
|
||||
return journalProtocolConfigurationForSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteType: REMOTE_POSTGREST,
|
||||
...settings,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
}
|
||||
|
||||
export function postgRESTJournalFormFromSettings(settings: PostgRESTSyncSetting): PostgRESTJournalForm {
|
||||
const activeConnectionURI = settings.postgrestActiveConnectionURI.trim();
|
||||
const connection = activeConnectionURI
|
||||
? parsePostgRESTConnectionURI(activeConnectionURI)
|
||||
: emptyPostgRESTConnection;
|
||||
const protocol = resolveProtocol({
|
||||
...settings,
|
||||
expectedRepositoryId: activeConnectionURI ? settings.expectedRepositoryId : "",
|
||||
});
|
||||
return {
|
||||
...connection,
|
||||
expectedRepositoryId: protocol.expectedRepositoryId,
|
||||
};
|
||||
}
|
||||
|
||||
export function postgRESTSyncSettingsFromForm(form: PostgRESTJournalForm): PostgRESTSyncSetting {
|
||||
const settings: PostgRESTSyncSetting = {
|
||||
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
|
||||
apiKey: form.apiKey.trim(),
|
||||
endpoint: form.endpoint.trim(),
|
||||
schema: form.schema.trim(),
|
||||
useCustomRequestHandler: form.useCustomRequestHandler,
|
||||
vaultCredential: form.vaultCredential,
|
||||
vaultId: form.vaultId.trim(),
|
||||
}),
|
||||
expectedRepositoryId: form.expectedRepositoryId.trim(),
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
};
|
||||
resolveProtocol(settings);
|
||||
return settings;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { serialisePostgRESTConnectionURI } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import { postgRESTJournalFormFromSettings, postgRESTSyncSettingsFromForm } from "./postgRESTJournalSettings.ts";
|
||||
|
||||
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
describe("PostgREST Journal settings", () => {
|
||||
it("round-trips client connection fields with the fixed Adaptive protocol", () => {
|
||||
const settings = {
|
||||
postgrestActiveConnectionURI: serialisePostgRESTConnectionURI({
|
||||
apiKey: "publishable-key",
|
||||
endpoint: "https://project.example/rest/v1",
|
||||
schema: "private_sync",
|
||||
useCustomRequestHandler: true,
|
||||
vaultCredential: "credential with spaces",
|
||||
vaultId: "vault-id-00000001",
|
||||
}),
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "whole-pack" as const,
|
||||
};
|
||||
|
||||
const form = postgRESTJournalFormFromSettings(settings);
|
||||
|
||||
expect(form).toEqual({
|
||||
apiKey: "publishable-key",
|
||||
endpoint: "https://project.example/rest/v1",
|
||||
expectedRepositoryId: repositoryId,
|
||||
schema: "private_sync",
|
||||
useCustomRequestHandler: true,
|
||||
vaultCredential: "credential with spaces",
|
||||
vaultId: "vault-id-00000001",
|
||||
});
|
||||
expect(postgRESTSyncSettingsFromForm(form)).toEqual(settings);
|
||||
});
|
||||
|
||||
it("uses the exposed-schema default for a new profile", () => {
|
||||
expect(
|
||||
postgRESTJournalFormFromSettings({
|
||||
postgrestActiveConnectionURI: "",
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "range",
|
||||
})
|
||||
).toEqual({
|
||||
apiKey: "",
|
||||
endpoint: "",
|
||||
expectedRepositoryId: "",
|
||||
schema: "livesync_api",
|
||||
useCustomRequestHandler: false,
|
||||
vaultCredential: "",
|
||||
vaultId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid pinned repository identity", () => {
|
||||
expect(() =>
|
||||
postgRESTSyncSettingsFromForm({
|
||||
apiKey: "publishable-key",
|
||||
endpoint: "https://project.example/rest/v1",
|
||||
expectedRepositoryId: "AA",
|
||||
schema: "livesync_api",
|
||||
useCustomRequestHandler: false,
|
||||
vaultCredential: "vault-credential",
|
||||
vaultId: "vault-id-00000001",
|
||||
})
|
||||
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
EncryptionSettings,
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
PostgRESTSyncSetting,
|
||||
WebDAVSyncSetting,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
@@ -112,6 +113,13 @@ export type SetupRemoteWebDAVInitialData = {
|
||||
mode: WebDAVSetupMode;
|
||||
};
|
||||
|
||||
export type SetupRemotePostgRESTResultType = typeof TYPE_CANCELLED | PostgRESTSyncSetting;
|
||||
export type PostgRESTSetupMode = "onboarding" | "settings";
|
||||
export type SetupRemotePostgRESTInitialData = {
|
||||
settings: PostgRESTSyncSetting;
|
||||
mode: PostgRESTSetupMode;
|
||||
};
|
||||
|
||||
export type SetupRemoteCouchDBResultType = typeof TYPE_CANCELLED | CouchDBConnection;
|
||||
export type CouchDBSetupMode = "create-or-connect" | "connect-existing" | "settings";
|
||||
export type SetupRemoteCouchDBInitialData = {
|
||||
|
||||
Reference in New Issue
Block a user