From f67d224657a5efefc179bbc76e180d971705ca57 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 1 Aug 2026 12:46:15 +0000 Subject: [PATCH] feat: configure Adaptive PostgREST journal remotes --- docs/settings.md | 30 +- .../messages/LiveSyncProvisionalMessages.ts | 37 ++ .../SettingDialogue/PaneRemoteConfig.ts | 10 + .../SettingDialogue/remoteConfigBuffer.ts | 2 + .../remoteConfigBuffer.unit.spec.ts | 32 ++ src/modules/features/SetupManager.ts | 15 + .../features/SetupManager.unit.spec.ts | 83 ++++ .../builtInRemoteSetupProviders.ts | 31 ++ .../dialogs/SetupRemotePostgREST.svelte | 380 ++++++++++++++++++ .../dialogs/postgRESTJournalSettings.ts | 65 +++ .../postgRESTJournalSettings.unit.spec.ts | 70 ++++ .../SetupWizard/dialogs/setupDialogTypes.ts | 8 + updates.md | 1 + 13 files changed, 761 insertions(+), 3 deletions(-) create mode 100644 src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte create mode 100644 src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.ts create mode 100644 src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.unit.spec.ts diff --git a/docs/settings.md b/docs/settings.md index 9473725b..60217be7 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -58,7 +58,7 @@ A current Setup URI retains its remote profiles, display names, and separate mai Step-by-step setup for Self-hosted LiveSync. You can setup Self-hosted LiveSync manually with Minimal setting items. -Completing manual CouchDB, Object Storage, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB and Object Storage setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later. +Completing manual CouchDB, Object Storage, WebDAV, PostgREST, or P2P setup creates the corresponding remote profile without replacing profiles which are already saved. CouchDB, Object Storage, WebDAV, and PostgREST setup select the new profile as the main remote. P2P setup selects it for P2P use and, when the wizard is enabling LiveSync, also selects it as the main remote. A descriptive display name is generated and can be changed later. #### Enable LiveSync @@ -171,7 +171,7 @@ Show verbose log. Please enable when you report the logs ### 1. Connection settings -Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** → **Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault. +Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** → **Saved connections**. Each profile represents a CouchDB database, a Journal storage connection, or a P2P configuration, and several profiles can be kept in one Vault. Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`. @@ -355,7 +355,31 @@ The dialogue reports required operations and Range support independently. Authen Onboarding accepts the profile only after the selected policy passes. Adding or editing a profile in Settings also offers **Save without connecting**, which preserves a locally valid but unverified profile. -### 7. CouchDB +### 7. PostgREST Journal + +PostgREST Journal is an experimental, Adaptive-only provider. It uses the packaged PostgREST RPC schema rather than exposing synchronisation tables or acting as a CouchDB replication endpoint. + +#### PostgREST connection + +Setting key: postgrestActiveConnectionURI + +The saved `sls+postgrest` value contains the HTTP or HTTPS endpoint, exposed schema, Vault ID, Vault credential, optional client-safe API key, and internal-API preference. A trusted database administrator provisions the Vault ID and credential by following the SQL package instructions. Do not enter a database credential, Supabase secret key, or `service_role` JWT in a client profile. + +Only HTTPS endpoints work on Obsidian Mobile. **Use internal API** routes requests through Obsidian when browser-compatible requests are blocked by CORS. + +#### Adaptive format and repository identity + +PostgREST always uses `adaptive-v1` with native Chunk rows and does not expose Opaque Journal, object Pack, Catalogue, Delta, or Range-retrieval options. Existing data in another Journal format is not read or migrated. Changing the wire or SQL format requires detection and a remote Rebuild. + +Setting key: expectedRepositoryId + +The optional expected repository ID pins a trusted Adaptive repository in the same way as other Adaptive Journal providers. + +#### Server capability check + +The setup dialogue verifies the installed RPC operations, Vault authentication, and exact binary behaviour through the provider-neutral Journal inspection boundary. Onboarding accepts the profile only after this check succeeds. Settings can preserve a locally valid profile without connecting so that an unavailable endpoint can be corrected later. + +### 8. CouchDB These settings are configured within the CouchDB Setup dialogue when adding (`➕`) or editing (`🔧`) a CouchDB connection profile. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 0887733a..130a303c 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -88,6 +88,43 @@ export const liveSyncProvisionalEnglishMessages = { "Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.": "Store Journal data in a dedicated WebDAV collection. Adaptive mode is experimental and requires an endpoint safety check.", "Continue to WebDAV setup": "Continue to WebDAV setup", + "PostgREST Journal Configuration": "PostgREST Journal Configuration", + "Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.": + "Connect to the packaged, Adaptive-only PostgREST RPC contract. This experimental provider is not a CouchDB endpoint and does not expose synchronisation tables directly.", + "Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.": + "Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment.", + "Vault ID": "Vault ID", + "Vault credential": "Vault credential", + "Enter the provisioned Vault credential": "Enter the provisioned Vault credential", + "A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.": + "A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential.", + "Exposed schema": "Exposed schema", + "Client API key (optional)": "Client API key (optional)", + "Supabase publishable key, if required": "Supabase publishable key, if required", + "Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.": + "Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential.", + "Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.": + "Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential.", + "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.": + "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.", + "The required PostgREST RPC operations and binary semantics were verified.": + "The required PostgREST RPC operations and binary semantics were verified.", + "The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.": + "The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", + "The PostgREST SQL contract is unavailable or incompatible with this build.": + "The PostgREST SQL contract is unavailable or incompatible with this build.", + "This build cannot inspect PostgREST Journal capabilities.": + "This build cannot inspect PostgREST Journal capabilities.", + "Invalid PostgREST settings: ${REASON}": "Invalid PostgREST settings: ${REASON}", + "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.": + "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.", + "PostgREST Journal": "PostgREST Journal", + "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.": + "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.", + "Continue to PostgREST setup": "Continue to PostgREST setup", + "Check PostgREST server": "Check PostgREST server", + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.": + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected.", "Expected repository ID": "Expected repository ID", "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.": "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.", diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 5e242238..aeb62979 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -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" }); diff --git a/src/modules/features/SettingDialogue/remoteConfigBuffer.ts b/src/modules/features/SettingDialogue/remoteConfigBuffer.ts index f9d903ff..c405a272 100644 --- a/src/modules/features/SettingDialogue/remoteConfigBuffer.ts +++ b/src/modules/features/SettingDialogue/remoteConfigBuffer.ts @@ -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), }); diff --git a/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts b/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts index 2790fcd8..5586a3a3 100644 --- a/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts +++ b/src/modules/features/SettingDialogue/remoteConfigBuffer.unit.spec.ts @@ -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"); + }); }); diff --git a/src/modules/features/SetupManager.ts b/src/modules/features/SetupManager.ts index 5c9a9446..2c34d563 100644 --- a/src/modules/features/SetupManager.ts +++ b/src/modules/features/SetupManager.ts @@ -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 { + return await this.onRemoteManualSetup("postgrest", userMode, currentSetting, activate); + } + /** * Handles manual setup for P2P * @param userMode diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index a68fc4d1..1552d042 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -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 = { diff --git a/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts b/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts index 0a0e4297..971d69dd 100644 --- a/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts +++ b/src/modules/features/SetupWizard/builtInRemoteSetupProviders.ts @@ -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 +): RemoteSetupRegistry { + const descriptor: RemoteSetupProviderDescriptor> = { + 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 { const registry = new RemoteSetupRegistry(); useCouchDBRemoteSetup(registry); useS3RemoteSetup(registry); useWebDAVRemoteSetup(registry); + usePostgRESTRemoteSetup(registry); useP2PRemoteSetup(registry); return registry; } diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte new file mode 100644 index 00000000..acfdbc74 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/SetupRemotePostgREST.svelte @@ -0,0 +1,380 @@ + + + + + {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." + )} + + + + + + + {translateMessage("We can use only Secure (HTTPS) connections on Obsidian Mobile.")} + + + {translateMessage( + "Enter a complete HTTP or HTTPS PostgREST endpoint without database credentials, a query string, or a fragment." + )} + + + + + + + + + + {translateMessage( + "A trusted database administrator obtains both values once from livesync_private.provision_adaptive_vault(). PostgreSQL retains only a verifier for the credential." + )} + + + + + + + + + + {translateMessage( + "Use only a publishable or equivalent client-safe API key. Never enter a Supabase secret key, service_role JWT, or database credential." + )} + + + {translateMessage( + "Supply a valid endpoint, PostgreSQL schema identifier, provisioned Vault ID, and Vault credential." + )} + + + + + + + {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." + )} + + + + + + + + {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." + )} + + + + + {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." + )} + +{#if requiredCapability?.status === "verified"} + + {translateMessage("The required PostgREST RPC operations and binary semantics were verified.")} + +{:else if requiredCapability?.status === "unsupported"} + + {translateMessage("The PostgREST SQL contract is missing required operations: ${CAPABILITIES}.", { + CAPABILITIES: requiredCapability.missing.join(", "), + })} + +{:else if requiredCapability?.status === "failed"} + + {translateMessage("The Adaptive safety check failed (${CATEGORY}; retry ${RETRY}).", { + CATEGORY: requiredCapability.failure.category, + RETRY: requiredCapability.failure.retry, + })} + +{:else if inspectionIsCurrent} + {translateMessage("Required Adaptive operations were not checked.")} +{/if} + + + {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." + )} + +{error} + +{#if processing} + {translateMessage("Checking connection... Please wait.")} +{:else} + + {#if inspectionIsCurrent && inspection?.available && inspectedSettings} + commitVerified()} + /> + checkConnection()} + /> + {:else} + checkConnection()} + /> + {/if} + {#if setupMode === "settings"} + + {translateMessage( + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection or server SQL is corrected." + )} + + commit()} + /> + {/if} + setResult(TYPE_CANCELLED)} /> + +{/if} diff --git a/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.ts b/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.ts new file mode 100644 index 00000000..94d6beb9 --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.ts @@ -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; +} diff --git a/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.unit.spec.ts b/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.unit.spec.ts new file mode 100644 index 00000000..396c3a7b --- /dev/null +++ b/src/modules/features/SetupWizard/dialogs/postgRESTJournalSettings.unit.spec.ts @@ -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"); + }); +}); diff --git a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts index 71da2e32..3149c1ea 100644 --- a/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts +++ b/src/modules/features/SetupWizard/dialogs/setupDialogTypes.ts @@ -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 = { diff --git a/updates.md b/updates.md index 9feb4437..f012dac7 100644 --- a/updates.md +++ b/updates.md @@ -18,6 +18,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel - Object Storage setup can select the experimental Adaptive Journal format and choose complete Pack or verified Range retrieval. Existing Opaque Journal repositories remain the default and require an explicit remote Rebuild before changing formats. - Saved connections and Setup now support experimental WebDAV Journal profiles. Adaptive WebDAV runs an endpoint safety check, reports required immutable-object behaviour separately from optional Range support, and permits an unverified save only from Settings. +- Saved connections and Setup now support experimental, Adaptive-only PostgREST Journal profiles. Setup verifies the packaged RPC contract and binary semantics, the adapter rejects recognised privileged Supabase keys before a request, and Opaque Journal, Pack, and Range options remain outside this provider. ### P2P and experimental browser applications