mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-04 10:45:44 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db64f6faa | ||
|
|
032ace8f53 | ||
|
|
1543a53263 | ||
|
|
9715d44fb6 | ||
|
|
dd8c795210 | ||
|
|
fff8103e40 | ||
|
|
aeb97c2b13 | ||
|
|
44c52aa2dd | ||
|
|
d74e37559c | ||
|
|
5c7af20ad2 | ||
|
|
0f2efbd670 | ||
|
|
a9283be9d2 | ||
|
|
4d6a794f9d | ||
|
|
c4419a837c |
@@ -301,6 +301,18 @@ Setting key: bucketCustomHeaders
|
||||
|
||||
Custom HTTP headers to include in every request sent to the Object Storage bucket. Specify them in the format `Header-Name: Value`, with each header on a new line.
|
||||
|
||||
#### Journal data format
|
||||
|
||||
Setting key: journalFormat
|
||||
|
||||
Existing Object Storage profiles use `opaque-v1` unless Adaptive Journal is selected explicitly. `adaptive-v1` uses an authenticated manifest and immutable Commit Bundles under a separate namespace, with larger Packs stored separately. Changing between formats requires an explicit remote Rebuild; LiveSync does not migrate or read both representations.
|
||||
|
||||
#### Pack retrieval
|
||||
|
||||
Setting key: packReadPolicy
|
||||
|
||||
Adaptive Journal can download a complete immutable Pack (`whole-pack`) or request only the required byte ranges (`range`). Complete Pack retrieval is the portable, throughput-oriented default. Selecting Range requires exact byte-range support from the configured S3-compatible endpoint. The connection test verifies that capability, and synchronisation refuses an unsupported selection before writing.
|
||||
|
||||
#### Test Connection
|
||||
|
||||
#### Apply Settings
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"test:contract:context:obsidian": "npm run build && npm run test:e2e:obsidian:smoke",
|
||||
"test:e2e:cli": "npm run test:e2e:ci --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:p2p": "npm run test:e2e:p2p --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:adaptive-s3": "npm run test:e2e:adaptive-s3 --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:adaptive-webdav": "npm run test:e2e:adaptive-webdav --workspace self-hosted-livesync-cli",
|
||||
"test:e2e:cli:all": "npm run test:e2e:all --workspace self-hosted-livesync-cli",
|
||||
"test:integration": "npx dotenv-cli -e .env -e .test.env -- vitest run --config vitest.config.integration.ts",
|
||||
"test:unit:coverage": "vitest run --config vitest.config.unit.ts --coverage",
|
||||
|
||||
@@ -15,13 +15,13 @@ import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/co
|
||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||
import { useJournalSyncFeature } from "@vrtmrz/livesync-commonlib/journal-sync";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||
@@ -139,7 +139,6 @@ export class LiveSyncBaseCore<
|
||||
public registerModules(extraModules: AbstractModule[] = []) {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
@@ -279,6 +278,8 @@ export class LiveSyncBaseCore<
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
// Journal providers are selected through the composed remote-provider registry.
|
||||
useJournalSyncFeature(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FilePathWithPrefix,
|
||||
type ObsidianLiveSyncSettings,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
isJournalRemoteType,
|
||||
type EntryMilestoneInfo,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -25,6 +25,8 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { JournalSyncCore } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncCore";
|
||||
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||
|
||||
function redactConnectionString(uri: string): string {
|
||||
@@ -59,8 +61,20 @@ async function verifyRemoteState(
|
||||
return false;
|
||||
}
|
||||
milestone = await dbRet.db.get(MILESTONE_DOCID);
|
||||
} else if (settings.remoteType === REMOTE_MINIO) {
|
||||
milestone = await (replicator as LiveSyncJournalReplicator).client.downloadJson("_00000000-milestone.json");
|
||||
} else if (isJournalRemoteType(settings.remoteType)) {
|
||||
const journalReplicator = replicator as LiveSyncJournalReplicator;
|
||||
if (journalProtocolConfigurationForSettings(settings).journalFormat === "adaptive-v1") {
|
||||
try {
|
||||
await journalReplicator.client.ensureCheckpointCachesAreFresh();
|
||||
standardIo.writeStderr("[Verification] Adaptive Journal repository is available.\n");
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
standardIo.writeStderr(`[Verification] Failed to verify Adaptive Journal repository: ${message}\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
milestone = await (journalReplicator.client as JournalSyncCore).downloadJson("_00000000-milestone.json");
|
||||
}
|
||||
|
||||
if (milestone) {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import * as processSetting from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import { configURIBase } from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
REMOTE_WEBDAV,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
@@ -717,6 +723,60 @@ describe("runCommand abnormal cases", () => {
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("verifies an Adaptive Journal repository without reading the legacy milestone", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteType = REMOTE_MINIO;
|
||||
settings.journalFormat = "adaptive-v1";
|
||||
settings.packReadPolicy = "whole-pack";
|
||||
|
||||
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
client: {
|
||||
ensureCheckpointCachesAreFresh,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
"[Verification] Adaptive Journal repository is available.\n"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the Adaptive verification path for a WebDAV remote", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
settings.remoteType = REMOTE_WEBDAV;
|
||||
settings.webDAVactiveConnectionURI = "sls+webdav://dav.example/dav";
|
||||
settings.journalFormat = "adaptive-v1";
|
||||
settings.packReadPolicy = "whole-pack";
|
||||
|
||||
const ensureCheckpointCachesAreFresh = vi.fn(async () => {});
|
||||
core.services.replicator.getActiveReplicator.mockReturnValue({
|
||||
nodeid: "test-node-id",
|
||||
initializeDatabaseForReplication: vi.fn(async () => {}),
|
||||
client: {
|
||||
ensureCheckpointCachesAreFresh,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||
...context,
|
||||
core,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ensureCheckpointCachesAreFresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("mark-resolved with remote-id temporarily activates it and runs markResolved", async () => {
|
||||
const core = createCoreMock();
|
||||
const settings = core.services.setting.currentSettings();
|
||||
|
||||
+26
-3
@@ -37,6 +37,14 @@ defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
/** Injectable command boundary used by CLI integration probes. */
|
||||
export type CliCommandRunner = (options: CLIOptions, context: CLICommandContext) => Promise<boolean>;
|
||||
|
||||
const SETTINGS_MANAGEMENT_COMMANDS: ReadonlySet<CLICommand> = new Set([
|
||||
"setup",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-set",
|
||||
"remote-activate",
|
||||
]);
|
||||
|
||||
function printHelp(standardIo: StandardIo): void {
|
||||
writeStdoutLine(
|
||||
standardIo,
|
||||
@@ -274,7 +282,10 @@ export async function main(
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(standardIo, `Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`
|
||||
);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
@@ -404,7 +415,10 @@ export async function main(
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
writeStderrLine(standardIo, `[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Info] Replication result received, but not processed automatically in CLI mode.`
|
||||
);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
@@ -506,7 +520,7 @@ export async function main(
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null!);
|
||||
let settingsBackup: string | null = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
@@ -576,6 +590,15 @@ export async function main(
|
||||
settingsPath,
|
||||
originalSyncSettings,
|
||||
});
|
||||
if (result && SETTINGS_MANAGEMENT_COMMANDS.has(options.command)) {
|
||||
// Settings management is intentional, unlike the temporary changes made by suspendAllSync().
|
||||
// setup replaces the complete configuration, while remote profile commands must retain the
|
||||
// synchronisation mode which was active before the command lifecycle suspended it.
|
||||
if (options.command !== "setup") {
|
||||
await core.services.setting.applyPartial(originalSyncSettings, true);
|
||||
}
|
||||
settingsBackup = await fs.readFile(settingsPath, "utf-8");
|
||||
}
|
||||
if (!result) {
|
||||
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"pretest:e2e:ci": "npm run build",
|
||||
"test:e2e:ci": "deno task --cwd testdeno test:ci",
|
||||
"test:e2e:p2p": "deno task --cwd testdeno test:p2p:compose",
|
||||
"pretest:e2e:adaptive-s3": "npm run build",
|
||||
"test:e2e:adaptive-s3": "deno task --cwd testdeno test:adaptive-journal-s3",
|
||||
"pretest:e2e:adaptive-webdav": "npm run build",
|
||||
"test:e2e:adaptive-webdav": "deno task --cwd testdeno test:adaptive-journal-webdav",
|
||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||
"test:e2e:remote-commands": "bash test/test-remote-commands-linux.sh",
|
||||
"pretest:e2e:all": "npm run build",
|
||||
|
||||
@@ -25,6 +25,10 @@ type SerializableContainer =
|
||||
| {
|
||||
[NODE_KV_TYPED_KEY]: "ArrayBuffer";
|
||||
[NODE_KV_VALUES_KEY]: number[];
|
||||
}
|
||||
| {
|
||||
[NODE_KV_TYPED_KEY]: "BigInt";
|
||||
[NODE_KV_VALUES_KEY]: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -32,6 +36,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function serializeForNodeKV(value: unknown): unknown {
|
||||
if (typeof value === "bigint") {
|
||||
return {
|
||||
[NODE_KV_TYPED_KEY]: "BigInt",
|
||||
[NODE_KV_VALUES_KEY]: value.toString(10),
|
||||
} satisfies SerializableContainer;
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
return {
|
||||
[NODE_KV_TYPED_KEY]: "Set",
|
||||
@@ -78,6 +88,9 @@ function deserializeFromNodeKV(value: unknown): unknown {
|
||||
if (taggedType === "ArrayBuffer" && Array.isArray(taggedValues)) {
|
||||
return Uint8Array.from(taggedValues).buffer;
|
||||
}
|
||||
if (taggedType === "BigInt" && typeof taggedValues === "string" && /^-?(?:0|[1-9]\d*)$/u.test(taggedValues)) {
|
||||
return BigInt(taggedValues);
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deserializeFromNodeKV(v)]));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { NodeKeyValueDBDependencies } from "./NodeKeyValueDBService";
|
||||
import { NodeKeyValueDBService } from "./NodeKeyValueDBService";
|
||||
|
||||
function createInitialisableDependencies(): {
|
||||
dependencies: NodeKeyValueDBDependencies;
|
||||
initialise: () => Promise<boolean>;
|
||||
} {
|
||||
let initialise: (() => Promise<boolean>) | undefined;
|
||||
const dependencies = {
|
||||
appLifecycle: {
|
||||
onSettingLoaded: {
|
||||
addHandler: vi.fn((handler: () => Promise<boolean>) => {
|
||||
initialise = handler;
|
||||
}),
|
||||
},
|
||||
},
|
||||
databaseEvents: {
|
||||
onResetDatabase: { addHandler: vi.fn() },
|
||||
onDatabaseInitialisation: { addHandler: vi.fn() },
|
||||
onUnloadDatabase: { addHandler: vi.fn() },
|
||||
onCloseDatabase: { addHandler: vi.fn() },
|
||||
},
|
||||
vault: {},
|
||||
} as unknown as NodeKeyValueDBDependencies;
|
||||
return {
|
||||
dependencies,
|
||||
initialise: async () => {
|
||||
if (!initialise) throw new Error("Initialisation handler was not registered");
|
||||
return await initialise();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
it("creates a namespaced store handle before the backing database is initialised", () => {
|
||||
const dependencies = {
|
||||
@@ -44,4 +75,29 @@ describe("NodeKeyValueDBService.openSimpleStore", () => {
|
||||
|
||||
await expect(store.get("key")).rejects.toThrow("KeyValueDB is not initialized yet");
|
||||
});
|
||||
|
||||
it("preserves bigint values used by Adaptive Journal state", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-node-kv-bigint-"));
|
||||
const filePath = path.join(tempDir, "keyvalue-db.json");
|
||||
const writerState = {
|
||||
lastCommittedSequence: 9007199254740993n,
|
||||
pendingCommit: { sequence: 18446744073709551615n },
|
||||
writerEpoch: "test-writer-epoch",
|
||||
};
|
||||
|
||||
try {
|
||||
const firstLifecycle = createInitialisableDependencies();
|
||||
const first = new NodeKeyValueDBService(createServiceContext(), firstLifecycle.dependencies, filePath);
|
||||
await expect(firstLifecycle.initialise()).resolves.toBe(true);
|
||||
await first.openSimpleStore("adaptive").set("writer-state", writerState);
|
||||
|
||||
const secondLifecycle = createInitialisableDependencies();
|
||||
const second = new NodeKeyValueDBService(createServiceContext(), secondLifecycle.dependencies, filePath);
|
||||
await expect(secondLifecycle.initialise()).resolves.toBe(true);
|
||||
|
||||
await expect(second.openSimpleStore("adaptive").get("writer-state")).resolves.toEqual(writerState);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"test:e2e-matrix:couchdb-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc0' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:couchdb-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: COUCHDB-enc1' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:minio-enc0": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc0' test-e2e-two-vaults-matrix.ts",
|
||||
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts"
|
||||
"test:e2e-matrix:minio-enc1": "deno test --env-file=.test.env -A --no-check --filter='e2e matrix: MINIO-enc1' test-e2e-two-vaults-matrix.ts",
|
||||
"test:adaptive-journal-s3": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-s3.ts",
|
||||
"test:adaptive-journal-webdav": "deno test --env-file=.test.env -A --no-check test-adaptive-journal-webdav.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@std/assert": "jsr:@std/assert@^1.0.13",
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CliResult {
|
||||
export const TEE_ENABLED = Deno.env.get("LIVESYNC_TEST_TEE") === "1";
|
||||
const VERBOSE_ENABLED = Deno.env.get("LIVESYNC_CLI_VERBOSE") === "1";
|
||||
const DEBUG_ENABLED = Deno.env.get("LIVESYNC_CLI_DEBUG") === "1";
|
||||
const SETUP_URI_PREFIX = "obsidian://setuplivesync?settings=";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -40,7 +41,13 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
||||
}
|
||||
|
||||
export function formatTeeCommand(args: string[]): string {
|
||||
return ["node", CLI_DIST, ...args].map((part) => JSON.stringify(part)).join(" ");
|
||||
const redactArgument = (argument: string): string => {
|
||||
if (argument.startsWith(SETUP_URI_PREFIX)) {
|
||||
return `${SETUP_URI_PREFIX}<redacted>`;
|
||||
}
|
||||
return argument.replace(/^(sls\+[^:]+:\/\/)[^/?#@]*@/u, "$1<redacted>@");
|
||||
};
|
||||
return ["node", CLI_DIST, ...args.map(redactArgument)].map((part) => JSON.stringify(part)).join(" ");
|
||||
}
|
||||
|
||||
export function createLineTeeWriter(
|
||||
|
||||
@@ -327,8 +327,41 @@ const COUCHDB_CONTAINER = "couchdb-test";
|
||||
const COUCHDB_IMAGE = "couchdb:3.5.0";
|
||||
|
||||
const MINIO_CONTAINER = "minio-test";
|
||||
const MINIO_IMAGE = "minio/minio";
|
||||
const MINIO_MC_IMAGE = "minio/mc";
|
||||
const MINIO_IMAGE = "minio/minio:RELEASE.2025-04-22T22-12-26Z";
|
||||
const MINIO_MC_IMAGE = "minio/mc:RELEASE.2025-04-16T18-13-26Z";
|
||||
|
||||
const WEBDAV_CONTAINER = "webdav-test";
|
||||
const WEBDAV_IMAGE = "httpd:2.4.68";
|
||||
const WEBDAV_HTTPD_CONFIG = `ServerRoot "/usr/local/apache2"
|
||||
Listen 80
|
||||
|
||||
LoadModule mpm_event_module modules/mod_mpm_event.so
|
||||
LoadModule authn_core_module modules/mod_authn_core.so
|
||||
LoadModule authz_core_module modules/mod_authz_core.so
|
||||
LoadModule dav_module modules/mod_dav.so
|
||||
LoadModule dav_fs_module modules/mod_dav_fs.so
|
||||
LoadModule unixd_module modules/mod_unixd.so
|
||||
|
||||
User www-data
|
||||
Group www-data
|
||||
ServerName localhost
|
||||
DocumentRoot "/usr/local/apache2/htdocs"
|
||||
PidFile "/tmp/httpd.pid"
|
||||
ErrorLog "/proc/self/fd/2"
|
||||
LogLevel warn
|
||||
|
||||
DavLockDB "/usr/local/apache2/var/DavLock"
|
||||
DavLockDiscovery Off
|
||||
|
||||
<Directory "/usr/local/apache2/htdocs">
|
||||
AllowOverride None
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<Directory "/usr/local/apache2/htdocs/dav">
|
||||
Dav On
|
||||
</Directory>
|
||||
`;
|
||||
|
||||
export async function stopCouchdb(): Promise<void> {
|
||||
await stopAndRemoveContainer(COUCHDB_CONTAINER);
|
||||
@@ -466,6 +499,70 @@ export async function stopMinio(): Promise<void> {
|
||||
untrackContainer(MINIO_CONTAINER);
|
||||
}
|
||||
|
||||
export async function listMinioObjectKeys(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string
|
||||
): Promise<string[]> {
|
||||
const cmd =
|
||||
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`mc ls --recursive --json myminio/${shQuote(bucket)}`;
|
||||
const result = await docker(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
MINIO_MC_IMAGE,
|
||||
"-c",
|
||||
cmd
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Could not list MinIO objects: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line) as { key?: unknown })
|
||||
.map(({ key }) => {
|
||||
if (typeof key !== "string") {
|
||||
throw new Error("MinIO returned an object without a string key");
|
||||
}
|
||||
return key;
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
export async function readMinioObjectText(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
secretKey: string,
|
||||
bucket: string,
|
||||
key: string
|
||||
): Promise<string> {
|
||||
const cmd =
|
||||
`mc alias set myminio ${shQuote(minioEndpoint)} ${shQuote(accessKey)} ${shQuote(secretKey)} >/dev/null 2>&1 && ` +
|
||||
`mc cat myminio/${shQuote(bucket)}/${shQuote(key)}`;
|
||||
const result = await docker(
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
MINIO_MC_IMAGE,
|
||||
"-c",
|
||||
cmd
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Could not read MinIO object ${key}: ${result.stderr.trim()}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
async function initMinioBucket(
|
||||
minioEndpoint: string,
|
||||
accessKey: string,
|
||||
@@ -561,6 +658,113 @@ export async function startMinio(
|
||||
await waitForMinioBucket(minioEndpoint, accessKey, secretKey, bucket);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebDAV
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function stopWebDAV(): Promise<void> {
|
||||
await stopAndRemoveContainer(WEBDAV_CONTAINER);
|
||||
untrackContainer(WEBDAV_CONTAINER);
|
||||
}
|
||||
|
||||
async function waitForWebDAV(endpoint: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "PROPFIND",
|
||||
headers: { Depth: "0" },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
await response.body?.cancel().catch(() => {});
|
||||
if (response.status === 207) return;
|
||||
} catch {
|
||||
// The container is still starting.
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`WebDAV collection did not become ready: ${endpoint}`);
|
||||
}
|
||||
|
||||
export async function startWebDAV(endpoint: string): Promise<void> {
|
||||
const url = new URL(endpoint);
|
||||
if (url.protocol !== "http:" || (url.hostname !== "127.0.0.1" && url.hostname !== "localhost")) {
|
||||
throw new Error(`Managed WebDAV requires a local HTTP endpoint, received: ${endpoint}`);
|
||||
}
|
||||
if (url.pathname.replace(/\/+$/u, "") !== "/dav") {
|
||||
throw new Error(`Managed WebDAV requires the /dav collection, received: ${endpoint}`);
|
||||
}
|
||||
const hostPort = url.port || "80";
|
||||
const encodedConfig = btoa(WEBDAV_HTTPD_CONFIG);
|
||||
const startCommand = `printf '%s' '${encodedConfig}' | base64 -d > /tmp/httpd.conf && exec httpd -DFOREGROUND -f /tmp/httpd.conf`;
|
||||
|
||||
console.log("[INFO] stopping leftover WebDAV container if present");
|
||||
await stopWebDAV().catch(() => {});
|
||||
|
||||
console.log("[INFO] starting Apache WebDAV test container");
|
||||
await dockerOrFail(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
WEBDAV_CONTAINER,
|
||||
"-p",
|
||||
`${hostPort}:80`,
|
||||
"--tmpfs",
|
||||
"/usr/local/apache2/htdocs/dav:mode=0777",
|
||||
"--tmpfs",
|
||||
"/usr/local/apache2/var:mode=0777",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
WEBDAV_IMAGE,
|
||||
"-c",
|
||||
startCommand
|
||||
);
|
||||
trackContainer(WEBDAV_CONTAINER);
|
||||
await waitForWebDAV(endpoint);
|
||||
}
|
||||
|
||||
function directWebDAVObjectUrl(collectionEndpoint: string, key: string): string {
|
||||
return `${collectionEndpoint.replace(/\/+$/u, "")}/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
function webDAVRequestHeaders(): HeadersInit {
|
||||
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
|
||||
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
|
||||
if (!username && !password) return {};
|
||||
return { Authorization: `Basic ${btoa(`${username}:${password}`)}` };
|
||||
}
|
||||
|
||||
export async function listWebDAVObjectKeys(collectionEndpoint: string): Promise<string[]> {
|
||||
const collectionUrl = new URL(`${collectionEndpoint.replace(/\/+$/u, "")}/`);
|
||||
const response = await fetch(collectionUrl, {
|
||||
method: "PROPFIND",
|
||||
headers: { ...webDAVRequestHeaders(), Depth: "1" },
|
||||
});
|
||||
if (response.status !== 207) {
|
||||
throw new Error(`Could not list WebDAV objects: HTTP ${response.status}`);
|
||||
}
|
||||
const xml = await response.text();
|
||||
const hrefs = [
|
||||
...xml.matchAll(/<(?:[A-Za-z_][\w.-]*:)?href\b[^>]*>([\s\S]*?)<\/(?:[A-Za-z_][\w.-]*:)?href>/giu),
|
||||
].map((match) => match[1].trim());
|
||||
const basePath = decodeURIComponent(collectionUrl.pathname);
|
||||
const keys = new Set<string>();
|
||||
for (const href of hrefs) {
|
||||
const path = decodeURIComponent(new URL(href, collectionUrl).pathname);
|
||||
if (!path.startsWith(basePath)) continue;
|
||||
const key = path.slice(basePath.length).replace(/\/$/u, "");
|
||||
if (key && !key.includes("/")) keys.add(key);
|
||||
}
|
||||
return [...keys].sort();
|
||||
}
|
||||
|
||||
export async function readWebDAVObjectText(collectionEndpoint: string, key: string): Promise<string> {
|
||||
const response = await fetch(directWebDAVObjectUrl(collectionEndpoint, key), { headers: webDAVRequestHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not read WebDAV object ${key}: HTTP ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P2P relay (strfry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,7 +13,12 @@ export async function initSettingsFile(settingsFile: string): Promise<void> {
|
||||
* Generate a full setup URI from a settings file via the Commonlib package API.
|
||||
* Mirrors the bash flow in test-setup-put-cat-linux.sh.
|
||||
*/
|
||||
export async function generateSetupUriFromSettings(settingsFile: string, setupPassphrase: string): Promise<string> {
|
||||
export async function generateSetupUriFromSettings(
|
||||
settingsFile: string,
|
||||
setupPassphrase: string,
|
||||
preserveSettings = false,
|
||||
runtimeVaultPassphrase?: string
|
||||
): Promise<string> {
|
||||
const script = [
|
||||
"import { fs } from '@vrtmrz/livesync-commonlib/node';",
|
||||
"import { encodeSettingsToSetupURI } from '@vrtmrz/livesync-commonlib/compat/API/processSetting';",
|
||||
@@ -21,13 +26,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
" const settingsPath = process.env.SETTINGS_FILE;",
|
||||
" const passphrase = process.env.SETUP_PASSPHRASE;",
|
||||
" const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
" if (process.env.RUNTIME_VAULT_PASSPHRASE !== undefined) {",
|
||||
" settings.passphrase = process.env.RUNTIME_VAULT_PASSPHRASE;",
|
||||
" }",
|
||||
" if (process.env.PRESERVE_SETTINGS !== 'true') {",
|
||||
" settings.couchDB_DBNAME = 'setup-put-cat-db';",
|
||||
" settings.couchDB_URI = 'http://127.0.0.1:5999';",
|
||||
" settings.couchDB_USER = 'dummy';",
|
||||
" settings.couchDB_PASSWORD = 'dummy';",
|
||||
" settings.liveSync = false;",
|
||||
" settings.syncOnStart = false;",
|
||||
" settings.syncOnSave = false;",
|
||||
" }",
|
||||
" const uri = await encodeSettingsToSetupURI(settings, passphrase);",
|
||||
" process.stdout.write(uri.trim());",
|
||||
"})();",
|
||||
@@ -41,13 +51,18 @@ export async function generateSetupUriFromSettings(settingsFile: string, setupPa
|
||||
await Deno.writeTextFile(scriptPath, script);
|
||||
|
||||
try {
|
||||
const env: Record<string, string> = {
|
||||
SETTINGS_FILE: settingsFile,
|
||||
SETUP_PASSPHRASE: setupPassphrase,
|
||||
PRESERVE_SETTINGS: preserveSettings ? "true" : "false",
|
||||
};
|
||||
if (runtimeVaultPassphrase !== undefined) {
|
||||
env.RUNTIME_VAULT_PASSPHRASE = runtimeVaultPassphrase;
|
||||
}
|
||||
const cmd = new Deno.Command("npx", {
|
||||
args: ["tsx", scriptPath],
|
||||
cwd: CLI_DIR,
|
||||
env: {
|
||||
SETTINGS_FILE: settingsFile,
|
||||
SETUP_PASSPHRASE: setupPassphrase,
|
||||
},
|
||||
env,
|
||||
stdin: "null",
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
@@ -112,7 +127,7 @@ export async function applyCouchdbSettings(
|
||||
export async function applyRemoteSyncSettings(
|
||||
settingsFile: string,
|
||||
options: {
|
||||
remoteType: "COUCHDB" | "MINIO";
|
||||
remoteType: "COUCHDB" | "MINIO" | "WEBDAV";
|
||||
couchdbUri?: string;
|
||||
couchdbUser?: string;
|
||||
couchdbPassword?: string;
|
||||
@@ -121,10 +136,14 @@ export async function applyRemoteSyncSettings(
|
||||
minioEndpoint?: string;
|
||||
minioAccessKey?: string;
|
||||
minioSecretKey?: string;
|
||||
webDAVConnectionURI?: string;
|
||||
encrypt?: boolean;
|
||||
passphrase?: string;
|
||||
enableCompression?: boolean;
|
||||
usePathObfuscation?: boolean;
|
||||
journalFormat?: "adaptive-v1" | "opaque-v1";
|
||||
expectedRepositoryId?: string;
|
||||
packReadPolicy?: "range" | "whole-pack";
|
||||
}
|
||||
): Promise<void> {
|
||||
const data = JSON.parse(await Deno.readTextFile(settingsFile));
|
||||
@@ -135,7 +154,7 @@ export async function applyRemoteSyncSettings(
|
||||
data.couchDB_USER = options.couchdbUser;
|
||||
data.couchDB_PASSWORD = options.couchdbPassword;
|
||||
data.couchDB_DBNAME = options.couchdbDbname;
|
||||
} else {
|
||||
} else if (options.remoteType === "MINIO") {
|
||||
data.remoteType = "MINIO";
|
||||
data.bucket = options.minioBucket;
|
||||
data.endpoint = options.minioEndpoint;
|
||||
@@ -143,6 +162,18 @@ export async function applyRemoteSyncSettings(
|
||||
data.secretKey = options.minioSecretKey;
|
||||
data.region = "auto";
|
||||
data.forcePathStyle = true;
|
||||
} else {
|
||||
data.remoteType = "WEBDAV";
|
||||
data.webDAVactiveConnectionURI = options.webDAVConnectionURI;
|
||||
}
|
||||
if (options.journalFormat !== undefined) {
|
||||
data.journalFormat = options.journalFormat;
|
||||
}
|
||||
if (options.expectedRepositoryId !== undefined) {
|
||||
data.expectedRepositoryId = options.expectedRepositoryId;
|
||||
}
|
||||
if (options.packReadPolicy !== undefined) {
|
||||
data.packReadPolicy = options.packReadPolicy;
|
||||
}
|
||||
|
||||
data.liveSync = true;
|
||||
|
||||
@@ -11,6 +11,8 @@ const TASKS = [
|
||||
"test:e2e-matrix:couchdb-enc1",
|
||||
"test:e2e-matrix:minio-enc0",
|
||||
"test:e2e-matrix:minio-enc1",
|
||||
"test:adaptive-journal-s3",
|
||||
"test:adaptive-journal-webdav",
|
||||
] as const;
|
||||
|
||||
for (const [index, task] of TASKS.entries()) {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startMinio, stopMinio } from "./helpers/docker.ts";
|
||||
|
||||
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
|
||||
|
||||
function deterministicBytes(length: number, seed: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
bytes[index] = state & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function requireEnv(...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = Deno.env.get(key)?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
throw new Error(`Required environment variable is missing: ${keys.join(" or ")}`);
|
||||
}
|
||||
|
||||
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal S3", async () => {
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const endpoint = requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/u, "");
|
||||
const accessKey = requireEnv("MINIO_ACCESS_KEY", "accessKey");
|
||||
const secretKey = requireEnv("MINIO_SECRET_KEY", "secretKey");
|
||||
const bucket = `${requireEnv("MINIO_BUCKET_NAME", "bucketName")}-${suffix}`;
|
||||
const passphrase = "adaptive-journal-cli-e2e-passphrase";
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-s3");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const binarySourceA = workDir.join("source-a.bin");
|
||||
const binarySourceB = workDir.join("source-b.bin");
|
||||
const binaryDestinationA = workDir.join("destination-a.bin");
|
||||
const binaryDestinationB = workDir.join("destination-b.bin");
|
||||
await Deno.mkdir(vaultA, { recursive: true });
|
||||
await Deno.mkdir(vaultB, { recursive: true });
|
||||
|
||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
||||
await startMinio(endpoint, accessKey, secretKey, bucket);
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsA);
|
||||
await initSettingsFile(settingsB);
|
||||
await applyRemoteSyncSettings(settingsA, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket: bucket,
|
||||
minioEndpoint: endpoint,
|
||||
minioAccessKey: accessKey,
|
||||
minioSecretKey: secretKey,
|
||||
encrypt: true,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
await applyRemoteSyncSettings(settingsB, {
|
||||
remoteType: "MINIO",
|
||||
minioBucket: bucket,
|
||||
minioEndpoint: endpoint,
|
||||
minioAccessKey: accessKey,
|
||||
minioSecretKey: secretKey,
|
||||
encrypt: true,
|
||||
passphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
});
|
||||
|
||||
const textPath = "adaptive/text.md";
|
||||
const binaryPath = "adaptive/data.bin";
|
||||
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
|
||||
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
|
||||
`created-by-a-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
|
||||
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
|
||||
|
||||
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
|
||||
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
|
||||
`updated-by-b-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
|
||||
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
|
||||
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
|
||||
} finally {
|
||||
if (!keepDocker) {
|
||||
await stopMinio().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { TempDir } from "./helpers/temp.ts";
|
||||
import { assertFilesEqual, runCli, runCliOrFail, runCliWithInputOrFail, sanitiseCatStdout } from "./helpers/cli.ts";
|
||||
import { applyRemoteSyncSettings, generateSetupUriFromSettings, initSettingsFile } from "./helpers/settings.ts";
|
||||
import { startWebDAV, stopWebDAV } from "./helpers/docker.ts";
|
||||
|
||||
const EXTERNAL_PACK_TEST_BYTES = 9 * 1024 * 1024;
|
||||
|
||||
function deterministicBytes(length: number, seed: number): Uint8Array {
|
||||
const bytes = new Uint8Array(length);
|
||||
let state = seed;
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
bytes[index] = state & 0xff;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function webDAVConnectionURI(endpoint: string, prefix: string): string {
|
||||
const endpointUrl = new URL(endpoint);
|
||||
const proxyUrl = new URL(`https://${endpointUrl.host}${endpointUrl.pathname}`);
|
||||
const username = Deno.env.get("WEBDAV_USERNAME") ?? "";
|
||||
const password = Deno.env.get("WEBDAV_PASSWORD") ?? "";
|
||||
proxyUrl.username = username;
|
||||
proxyUrl.password = password;
|
||||
if (endpointUrl.protocol === "http:") proxyUrl.searchParams.set("insecure", "true");
|
||||
proxyUrl.searchParams.set("prefix", prefix);
|
||||
return `sls+webdav:${proxyUrl.toString().slice("https:".length)}`;
|
||||
}
|
||||
|
||||
function setPackReadPolicy(connectionURI: string, policy: "range" | "whole-pack"): string {
|
||||
const url = new URL(connectionURI);
|
||||
url.searchParams.set("packReadPolicy", policy);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function selectAdaptiveJournal(connectionURI: string): string {
|
||||
const url = new URL(connectionURI);
|
||||
url.searchParams.set("journalFormat", "adaptive-v1");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function remoteIdFromListing(listing: string): string {
|
||||
const line = listing
|
||||
.split(/\r?\n/u)
|
||||
.find((candidate) => candidate.includes("\tWebDAV Remote\t") || candidate.includes("\tWebDAV "));
|
||||
const id = line?.split("\t", 1)[0];
|
||||
if (!id) throw new Error(`WebDAV remote profile was not listed:\n${listing}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
Deno.test("e2e: two CLI vaults synchronise through Adaptive Journal WebDAV", async () => {
|
||||
const suffix = `${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
const endpoint = (Deno.env.get("WEBDAV_ENDPOINT") ?? "http://127.0.0.1:8088/dav").replace(/\/+$/u, "");
|
||||
const prefix = `adaptive-cli-${suffix}/`;
|
||||
const connectionURI = webDAVConnectionURI(endpoint, prefix);
|
||||
const adaptiveConnectionURI = selectAdaptiveJournal(connectionURI);
|
||||
const vaultPassphrase = "adaptive-journal-webdav-cli-e2ee";
|
||||
const setupPassphrase = "adaptive-journal-webdav-cli-setup";
|
||||
|
||||
await using workDir = await TempDir.create("livesync-cli-adaptive-journal-webdav");
|
||||
const vaultA = workDir.join("vault-a");
|
||||
const vaultB = workDir.join("vault-b");
|
||||
const settingsA = workDir.join("settings-a.json");
|
||||
const settingsB = workDir.join("settings-b.json");
|
||||
const binarySourceA = workDir.join("source-a.bin");
|
||||
const binarySourceB = workDir.join("source-b.bin");
|
||||
const binaryDestinationA = workDir.join("destination-a.bin");
|
||||
const binaryDestinationB = workDir.join("destination-b.bin");
|
||||
await Deno.mkdir(vaultA, { recursive: true });
|
||||
await Deno.mkdir(vaultB, { recursive: true });
|
||||
|
||||
const shouldStartDocker = Deno.env.get("LIVESYNC_START_DOCKER") !== "0";
|
||||
const keepDocker = Deno.env.get("LIVESYNC_DEBUG_KEEP_DOCKER") === "1";
|
||||
if (shouldStartDocker) await startWebDAV(endpoint);
|
||||
|
||||
try {
|
||||
await initSettingsFile(settingsA);
|
||||
await applyRemoteSyncSettings(settingsA, {
|
||||
remoteType: "WEBDAV",
|
||||
webDAVConnectionURI: connectionURI,
|
||||
encrypt: true,
|
||||
passphrase: vaultPassphrase,
|
||||
enableCompression: false,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
const addedRemote = await runCliOrFail(
|
||||
vaultA,
|
||||
"--settings",
|
||||
settingsA,
|
||||
"remote-add",
|
||||
"WebDAV E2E",
|
||||
adaptiveConnectionURI
|
||||
);
|
||||
const remoteId = addedRemote.trim().split("\t", 1)[0];
|
||||
assert(remoteId, `remote-add did not return a profile ID:\n${addedRemote}`);
|
||||
const settingsAfterRemoteAdd = JSON.parse(await Deno.readTextFile(settingsA)) as {
|
||||
remoteConfigurations?: Record<string, unknown>;
|
||||
liveSync?: boolean;
|
||||
};
|
||||
assert(
|
||||
settingsAfterRemoteAdd.remoteConfigurations?.[remoteId],
|
||||
`remote-add did not persist profile ${remoteId}: ${Object.keys(settingsAfterRemoteAdd.remoteConfigurations ?? {}).join(", ")}`
|
||||
);
|
||||
assertEquals(settingsAfterRemoteAdd.liveSync, true, "remote-add changed the persisted synchronisation mode");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "remote-activate", remoteId);
|
||||
|
||||
const textPath = "adaptive/text.md";
|
||||
const binaryPath = "adaptive/data.bin";
|
||||
await runCliWithInputOrFail(`created-by-a-${suffix}\n`, vaultA, "--settings", settingsA, "put", textPath);
|
||||
await Deno.writeFile(binarySourceA, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x1a2b3c4d));
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "push", binarySourceA, binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
|
||||
const remoteListing = await runCliOrFail(vaultA, "--settings", settingsA, "remote-ls");
|
||||
assertEquals(remoteIdFromListing(remoteListing), remoteId);
|
||||
assert(
|
||||
remoteListing
|
||||
.split(/\r?\n/u)
|
||||
.some((line) => line.startsWith(`${remoteId}\t`) && line.includes("\tactive\t")),
|
||||
`Activated WebDAV profile was not listed as active:\n${remoteListing}`
|
||||
);
|
||||
const exportedConnection = (
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "remote-export", remoteId)
|
||||
).trim();
|
||||
assert(exportedConnection.startsWith("sls+webdav://"));
|
||||
assert(exportedConnection.includes("journalFormat=adaptive-v1"));
|
||||
assert(!exportedConnection.includes("packReadPolicy="));
|
||||
|
||||
const setupURI = await generateSetupUriFromSettings(settingsA, setupPassphrase, true, vaultPassphrase);
|
||||
await initSettingsFile(settingsB);
|
||||
await runCliWithInputOrFail(`${setupPassphrase}\n`, vaultB, "--settings", settingsB, "setup", setupURI);
|
||||
const settingsAfterSetup = JSON.parse(await Deno.readTextFile(settingsB)) as {
|
||||
encryptedPassphrase?: string;
|
||||
};
|
||||
assert(
|
||||
typeof settingsAfterSetup.encryptedPassphrase === "string" &&
|
||||
settingsAfterSetup.encryptedPassphrase.length > 0,
|
||||
"setup did not persist the encrypted Vault passphrase"
|
||||
);
|
||||
await runCliOrFail(
|
||||
vaultB,
|
||||
"--settings",
|
||||
settingsB,
|
||||
"remote-set",
|
||||
remoteId,
|
||||
setPackReadPolicy(exportedConnection, "range")
|
||||
);
|
||||
const rangeConnection = (await runCliOrFail(vaultB, "--settings", settingsB, "remote-export", remoteId)).trim();
|
||||
assert(rangeConnection.includes("packReadPolicy=range"));
|
||||
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultB, "--settings", settingsB, "cat", textPath)).trimEnd(),
|
||||
`created-by-a-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "pull", binaryPath, binaryDestinationB);
|
||||
await assertFilesEqual(binarySourceA, binaryDestinationB, "Adaptive Journal Range transfer differs");
|
||||
|
||||
await runCliWithInputOrFail(`updated-by-b-${suffix}\n`, vaultB, "--settings", settingsB, "put", textPath);
|
||||
await Deno.writeFile(binarySourceB, deterministicBytes(EXTERNAL_PACK_TEST_BYTES, 0x5e6f7788));
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "push", binarySourceB, binaryPath);
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
assertEquals(
|
||||
sanitiseCatStdout(await runCliOrFail(vaultA, "--settings", settingsA, "cat", textPath)).trimEnd(),
|
||||
`updated-by-b-${suffix}`
|
||||
);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "pull", binaryPath, binaryDestinationA);
|
||||
await assertFilesEqual(binarySourceB, binaryDestinationA, "Adaptive Journal whole-Pack transfer differs");
|
||||
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "rm", binaryPath);
|
||||
await runCliOrFail(vaultA, "--settings", settingsA, "sync");
|
||||
await runCliOrFail(vaultB, "--settings", settingsB, "sync");
|
||||
const deleted = await runCli(vaultB, "--settings", settingsB, "cat", binaryPath);
|
||||
assert(deleted.code !== 0, `Deleted binary remained readable:\n${deleted.combined}`);
|
||||
} finally {
|
||||
if (shouldStartDocker && !keepDocker) {
|
||||
await stopWebDAV().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -288,6 +288,13 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "生效中的远程配置",
|
||||
"zh-tw": "目前啟用的遠端設定",
|
||||
},
|
||||
"Adaptive Journal (experimental)": {
|
||||
def: "Adaptive Journal (experimental)",
|
||||
},
|
||||
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.":
|
||||
{
|
||||
def: "Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.",
|
||||
},
|
||||
"Add default patterns": {
|
||||
def: "Add default patterns",
|
||||
es: "Añadir patrones predeterminados",
|
||||
@@ -784,6 +791,10 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "兼容性(问题修复)",
|
||||
"zh-tw": "相容性(問題修復)",
|
||||
},
|
||||
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.":
|
||||
{
|
||||
def: "Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.",
|
||||
},
|
||||
"Compute revisions for chunks": {
|
||||
def: "Compute revisions for chunks",
|
||||
es: "Calcular revisiones para los chunks",
|
||||
@@ -1585,6 +1596,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
ko: "문서 기록",
|
||||
"zh-tw": "文件歷程",
|
||||
},
|
||||
"Download complete Packs": {
|
||||
def: "Download complete Packs",
|
||||
},
|
||||
Duplicate: {
|
||||
def: "Duplicate",
|
||||
es: "Duplicar",
|
||||
@@ -2605,6 +2619,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
ru: "Интервал (сек)",
|
||||
zh: "间隔(秒)",
|
||||
},
|
||||
"Invalid Object Storage settings: ${reason}": {
|
||||
def: "Invalid Object Storage settings: ${reason}",
|
||||
},
|
||||
INVERTED: {
|
||||
def: "INVERTED",
|
||||
es: "INVERTIDO",
|
||||
@@ -2617,6 +2634,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
def: "It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.",
|
||||
es: "Se recomienda encarecidamente crear una copia de seguridad antes de continuar. Continuar sin copia de seguridad puede provocar pérdida de datos.",
|
||||
},
|
||||
"Journal data format": {
|
||||
def: "Journal data format",
|
||||
},
|
||||
"Just for a minute, please!": {
|
||||
def: "Just for a minute, please!",
|
||||
es: "¡Solo un momento, por favor!",
|
||||
@@ -6187,6 +6207,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
def: "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.",
|
||||
es: "En este dispositivo, cambia a la aplicación de cámara o usa un lector de QR para escanear el código mostrado.",
|
||||
},
|
||||
"Opaque Journal (current format)": {
|
||||
def: "Opaque Journal (current format)",
|
||||
},
|
||||
Open: {
|
||||
def: "Open",
|
||||
es: "Abrir",
|
||||
@@ -6447,6 +6470,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
ru: "P2P Sync с name начат.",
|
||||
zh: "P2P Sync with ${name} have been started.",
|
||||
},
|
||||
"Pack retrieval": {
|
||||
def: "Pack retrieval",
|
||||
},
|
||||
"paneMaintenance.markDeviceResolvedAfterBackup": {
|
||||
def: "paneMaintenance.markDeviceResolvedAfterBackup",
|
||||
es: "Marcar el dispositivo como resuelto después de hacer una copia de seguridad",
|
||||
@@ -11069,6 +11095,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
def: "Use Random Number",
|
||||
es: "Usar número aleatorio",
|
||||
},
|
||||
"Use S3 Range requests": {
|
||||
def: "Use S3 Range requests",
|
||||
},
|
||||
"Use Segmented-splitter": {
|
||||
def: "Use Segmented-splitter",
|
||||
es: "Usar divisor segmentado",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"Action": "Action",
|
||||
"Activate": "Activate",
|
||||
"Active Remote Configuration": "Active Remote Configuration",
|
||||
"Adaptive Journal (experimental)": "Adaptive Journal (experimental)",
|
||||
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.": "Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.",
|
||||
"Add default patterns": "Add default patterns",
|
||||
"Add new connection": "Add new connection",
|
||||
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.",
|
||||
@@ -112,6 +114,7 @@
|
||||
"Compatibility (Metadata)": "Compatibility (Metadata)",
|
||||
"Compatibility (Remote Database)": "Compatibility (Remote Database)",
|
||||
"Compatibility (Trouble addressed)": "Compatibility (Trouble addressed)",
|
||||
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.": "Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.",
|
||||
"Compute revisions for chunks": "Compute revisions for chunks",
|
||||
"Configuration": "Configuration",
|
||||
"Configuration Encryption": "Configuration Encryption",
|
||||
@@ -212,6 +215,7 @@
|
||||
"Doctor.Message.SomeSkipped": "We left some issues as is. Shall I ask you again on next startup?",
|
||||
"Doctor.RULES.E2EE_V02500.REASON": "The End-to-End Encryption has got now more robust and faster. Also because, the previous E2EE was found to be compromised in a re-conducted code review. It should be applied as soon as possible. Really apologises for your inconvenience. And, this setting is not forward compatible. All synchronised devices must be updated to v0.25.0 or higher. Rebuilds are not required and will be converted from the new transfer to the new format, However, it is recommended to rebuild whenever possible.",
|
||||
"Document History": "Document History",
|
||||
"Download complete Packs": "Download complete Packs",
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate remote": "Duplicate remote",
|
||||
"E2EE Configuration": "E2EE Configuration",
|
||||
@@ -355,9 +359,11 @@
|
||||
"Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": "Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.",
|
||||
"Initialise journal sent history. On the next sync, every item except this device received will be sent again.": "Initialise journal sent history. On the next sync, every item except this device received will be sent again.",
|
||||
"Interval (sec)": "Interval (sec)",
|
||||
"Invalid Object Storage settings: ${reason}": "Invalid Object Storage settings: ${reason}",
|
||||
"INVERTED": "INVERTED",
|
||||
"Issue detection log:": "Issue detection log:",
|
||||
"It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.": "It is strongly advised to create a backup before proceeding. Continuing without a backup may lead to data loss.",
|
||||
"Journal data format": "Journal data format",
|
||||
"Just for a minute, please!": "Just for a minute, please!",
|
||||
"JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.": "JWT (JSON Web Token) authentication allows you to securely authenticate with the CouchDB server using tokens. Ensure that your CouchDB server is configured to accept JWTs and that the provided key and settings match the server's configuration. Incidentally, I have not verified it very thoroughly.",
|
||||
"JWT Algorithm": "JWT Algorithm",
|
||||
@@ -738,6 +744,7 @@
|
||||
"On the source device, open Obsidian.": "On the source device, open Obsidian.",
|
||||
"On this device, please keep this Vault open.": "On this device, please keep this Vault open.",
|
||||
"On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.": "On this device, switch to the camera app or use a QR code scanner to scan the displayed QR code.",
|
||||
"Opaque Journal (current format)": "Opaque Journal (current format)",
|
||||
"Open": "Open",
|
||||
"Open connection": "Open connection",
|
||||
"Open P2P Setup...": "Open P2P Setup...",
|
||||
@@ -768,6 +775,7 @@
|
||||
"P2P.SyncAlreadyRunning": "P2P Sync is already running.",
|
||||
"P2P.SyncCompleted": "P2P Sync completed.",
|
||||
"P2P.SyncStartedWith": "P2P Sync with ${name} have been started.",
|
||||
"Pack retrieval": "Pack retrieval",
|
||||
"paneMaintenance.markDeviceResolvedAfterBackup": "paneMaintenance.markDeviceResolvedAfterBackup",
|
||||
"paneMaintenance.remoteLockedAndDeviceNotAccepted": "paneMaintenance.remoteLockedAndDeviceNotAccepted",
|
||||
"paneMaintenance.remoteLockedResolvedDevice": "paneMaintenance.remoteLockedResolvedDevice",
|
||||
@@ -1418,6 +1426,7 @@
|
||||
"Use JWT Authentication": "Use JWT Authentication",
|
||||
"Use Path-Style Access": "Use Path-Style Access",
|
||||
"Use Random Number": "Use Random Number",
|
||||
"Use S3 Range requests": "Use S3 Range requests",
|
||||
"Use Segmented-splitter": "Use Segmented-splitter",
|
||||
"Use splitting-limit-capped chunk splitter": "Use splitting-limit-capped chunk splitter",
|
||||
"Use the trash bin": "Use the trash bin",
|
||||
|
||||
@@ -66,6 +66,21 @@ AddOn Module (HiddenFileSync) has not been loaded. This is very unexpected situa
|
||||
situation. Please report this issue.
|
||||
Advanced: Advanced
|
||||
Advanced Settings: Advanced Settings
|
||||
Adaptive Journal (experimental): Adaptive Journal (experimental)
|
||||
Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats.:
|
||||
Adaptive Journal uses immutable objects and a separate remote format.
|
||||
Existing Opaque Journal data is not migrated or read. Rebuild the remote when
|
||||
changing formats.
|
||||
Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing.:
|
||||
Complete Pack reads favour throughput. Range reads can reduce transferred
|
||||
bytes. The connection test verifies exact Range support on this endpoint, and
|
||||
synchronisation refuses an unsupported selection before writing.
|
||||
Download complete Packs: Download complete Packs
|
||||
"Invalid Object Storage settings: ${reason}": "Invalid Object Storage settings: ${reason}"
|
||||
Journal data format: Journal data format
|
||||
Opaque Journal (current format): Opaque Journal (current format)
|
||||
Pack retrieval: Pack retrieval
|
||||
Use S3 Range requests: Use S3 Range requests
|
||||
After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that any unintended data currently on the server will be completely overwritten.:
|
||||
After restarting, the data on this device will be uploaded to the server as
|
||||
the 'master copy'. Please be aware that any unintended data currently on the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { REMOTE_MINIO, REMOTE_P2P, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
@@ -8,8 +9,7 @@ import type { LiveSyncCore } from "@/main";
|
||||
export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
// If new remote types were added, add them here. Do not use `REMOTE_COUCHDB` directly for the safety valve.
|
||||
if (settings.remoteType == REMOTE_MINIO || settings.remoteType == REMOTE_P2P) {
|
||||
if (!defaultRemoteProviderRegistry.isRemoteTypeInFamily(settings.remoteType, "couchdb")) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return Promise.resolve(new LiveSyncCouchDBReplicator(this.core));
|
||||
@@ -17,7 +17,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
_everyAfterResumeProcess(): Promise<boolean> {
|
||||
if (this.services.appLifecycle.isSuspended()) return Promise.resolve(true);
|
||||
if (!this.services.appLifecycle.isReady()) return Promise.resolve(true);
|
||||
if (this.settings.remoteType != REMOTE_MINIO && this.settings.remoteType != REMOTE_P2P) {
|
||||
if (defaultRemoteProviderRegistry.isRemoteTypeInFamily(this.settings.remoteType, "couchdb")) {
|
||||
const LiveSyncEnabled = this.settings.liveSync;
|
||||
const continuous = LiveSyncEnabled;
|
||||
const eventualOnStart = !LiveSyncEnabled && this.settings.syncOnStart;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_WEBDAV } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { ModuleReplicatorCouchDB } from "./ModuleReplicatorCouchDB.ts";
|
||||
|
||||
function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isReplicationReady = true) {
|
||||
function createModule(
|
||||
settings: { liveSync: boolean; remoteType?: string; syncOnStart: boolean },
|
||||
isReplicationReady = true
|
||||
) {
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const services = {
|
||||
@@ -30,7 +34,7 @@ function createModule(settings: { liveSync: boolean; syncOnStart: boolean }, isR
|
||||
_services: services,
|
||||
services,
|
||||
settings: {
|
||||
remoteType: "",
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
...settings,
|
||||
},
|
||||
replicator: { openReplication },
|
||||
@@ -86,4 +90,19 @@ describe("ModuleReplicatorCouchDB resume replication activity", () => {
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([REMOTE_MINIO, REMOTE_WEBDAV])("does not claim or resume registered Journal provider %s", async (remoteType) => {
|
||||
const { module, openReplication, runFiniteReplicationActivity } = createModule({
|
||||
liveSync: true,
|
||||
remoteType,
|
||||
syncOnStart: true,
|
||||
});
|
||||
|
||||
await expect(module._anyNewReplicator()).resolves.toBe(false);
|
||||
await module._everyAfterResumeProcess();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
|
||||
export class ModuleReplicatorMinIO extends AbstractModule {
|
||||
_anyNewReplicator(settingOverride: Partial<RemoteDBSettings> = {}): Promise<LiveSyncAbstractReplicator | false> {
|
||||
const settings = { ...this.settings, ...settingOverride };
|
||||
if (settings.remoteType == REMOTE_MINIO) {
|
||||
return Promise.resolve(new LiveSyncJournalReplicator(this.core));
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -21,18 +18,12 @@ import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts";
|
||||
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
||||
import {
|
||||
activateRemoteConfiguration,
|
||||
createRemoteConfigurationId,
|
||||
defaultRemoteProviderRegistry,
|
||||
suggestRemoteConfigurationName,
|
||||
type BuiltInRemoteConfiguration,
|
||||
type RemoteConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import type { RemoteConfigurationResult } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import SetupRemote from "@/modules/features/SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "@/modules/features/SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "@/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "@/modules/features/SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import type {
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
} from "@/modules/features/SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
import { syncActivatedRemoteSettings } from "./remoteConfigBuffer.ts";
|
||||
|
||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||
@@ -43,10 +34,6 @@ function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianL
|
||||
}
|
||||
return workObj;
|
||||
}
|
||||
function createRemoteConfigurationId(): string {
|
||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function cloneRemoteConfigurations(
|
||||
configs: Record<string, RemoteConfiguration> | undefined
|
||||
): Record<string, RemoteConfiguration> {
|
||||
@@ -54,13 +41,10 @@ function cloneRemoteConfigurations(
|
||||
}
|
||||
|
||||
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
||||
if (settings.remoteType === REMOTE_MINIO) {
|
||||
return ConnectionStringParser.serialize({ type: "s3", settings });
|
||||
}
|
||||
if (settings.remoteType === REMOTE_P2P) {
|
||||
return ConnectionStringParser.serialize({ type: "p2p", settings });
|
||||
}
|
||||
return ConnectionStringParser.serialize({ type: "couchdb", settings });
|
||||
const type = defaultRemoteProviderRegistry.typeForRemoteType(settings.remoteType);
|
||||
if (!type) throw new Error(`Unsupported remote type: ${settings.remoteType}`);
|
||||
const configuration = defaultRemoteProviderRegistry.configurationFromSettings(type, settings);
|
||||
return defaultRemoteProviderRegistry.serialise(configuration);
|
||||
}
|
||||
|
||||
function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string) {
|
||||
@@ -71,21 +55,6 @@ function setEmojiButton(button: ButtonComponent, emoji: string, tooltip: string)
|
||||
return button;
|
||||
}
|
||||
|
||||
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
|
||||
if (parsed.type === "couchdb") {
|
||||
try {
|
||||
const url = new URL(parsed.settings.couchDB_URI);
|
||||
return `CouchDB ${url.host}`;
|
||||
} catch {
|
||||
return "Imported CouchDB";
|
||||
}
|
||||
}
|
||||
if (parsed.type === "s3") {
|
||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
}
|
||||
|
||||
export function paneRemoteConfig(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -189,48 +158,10 @@ export function paneRemoteConfig(
|
||||
};
|
||||
const runRemoteSetup = async (
|
||||
baseSettings: ObsidianLiveSyncSettings,
|
||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
|
||||
type?: BuiltInRemoteConfiguration["type"]
|
||||
): Promise<ObsidianLiveSyncSettings | false> => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
let targetRemoteType = remoteType;
|
||||
|
||||
if (targetRemoteType === undefined) {
|
||||
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
|
||||
if (method === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
targetRemoteType =
|
||||
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_MINIO) {
|
||||
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
|
||||
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_P2P) {
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
|
||||
}
|
||||
|
||||
const couchConf = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: baseSettings,
|
||||
mode: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
|
||||
return await setupManager.configureRemoteForSettings(baseSettings, type);
|
||||
};
|
||||
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -282,9 +213,9 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: RemoteConfigurationResult;
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(trimmedURI);
|
||||
parsed = defaultRemoteProviderRegistry.parse(trimmedURI);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(`Failed to import remote configuration!`, LOG_LEVEL_NOTICE);
|
||||
this.services.API.addLog(ex, LOG_LEVEL_VERBOSE);
|
||||
@@ -302,7 +233,7 @@ export function paneRemoteConfig(
|
||||
configs[id] = {
|
||||
id,
|
||||
name: name.trim() || defaultName,
|
||||
uri: ConnectionStringParser.serialize(parsed),
|
||||
uri: defaultRemoteProviderRegistry.serialise(parsed),
|
||||
isEncrypted: false,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = configs;
|
||||
@@ -337,9 +268,9 @@ export function paneRemoteConfig(
|
||||
|
||||
row.addButton((btn) =>
|
||||
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
|
||||
let parsed: RemoteConfigurationResult;
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}' for editing!`,
|
||||
@@ -349,16 +280,9 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
if (parsed.type === "couchdb") {
|
||||
workSettings.remoteType = REMOTE_COUCHDB;
|
||||
} else if (parsed.type === "s3") {
|
||||
workSettings.remoteType = REMOTE_MINIO;
|
||||
} else {
|
||||
workSettings.remoteType = REMOTE_P2P;
|
||||
}
|
||||
Object.assign(workSettings, parsed.settings);
|
||||
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||
|
||||
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
|
||||
const nextSettings = await runRemoteSetup(workSettings, parsed.type);
|
||||
if (!nextSettings) {
|
||||
return;
|
||||
}
|
||||
@@ -447,9 +371,9 @@ export function paneRemoteConfig(
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("📡 Fetch remote settings").onClick(async () => {
|
||||
let parsed: RemoteConfigurationResult;
|
||||
let parsed: BuiltInRemoteConfiguration;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(config.uri);
|
||||
parsed = defaultRemoteProviderRegistry.parse(config.uri);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(
|
||||
`Failed to parse remote configuration '${config.id}' for fetching settings!`,
|
||||
@@ -459,14 +383,7 @@ export function paneRemoteConfig(
|
||||
return;
|
||||
}
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
if (parsed.type === "couchdb") {
|
||||
workSettings.remoteType = REMOTE_COUCHDB;
|
||||
} else if (parsed.type === "s3") {
|
||||
workSettings.remoteType = REMOTE_MINIO;
|
||||
} else {
|
||||
workSettings.remoteType = REMOTE_P2P;
|
||||
}
|
||||
Object.assign(workSettings, parsed.settings);
|
||||
defaultRemoteProviderRegistry.applyConfiguration(workSettings, parsed);
|
||||
const newTweaks =
|
||||
await this.services.tweakValue.checkAndAskUseRemoteConfiguration(
|
||||
workSettings
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
useCustomRequestHandler: false,
|
||||
forcePathStyle: true,
|
||||
bucketCustomHeaders: "",
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1" as const,
|
||||
packReadPolicy: "range" as const,
|
||||
};
|
||||
|
||||
syncActivatedRemoteSettings(target, source);
|
||||
@@ -40,6 +43,9 @@ describe("syncActivatedRemoteSettings", () => {
|
||||
expect(target.bucket).toBe("vault");
|
||||
expect(target.region).toBe("sz-hq");
|
||||
expect(target.bucketPrefix).toBe("folder/");
|
||||
expect(target.expectedRepositoryId).toBe("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
expect(target.journalFormat).toBe("adaptive-v1");
|
||||
expect(target.packReadPolicy).toBe("range");
|
||||
expect(target.encrypt).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
type EncryptionSettings,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type P2PSyncSetting,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { generateAdaptiveJournalRepositoryIdV1 } from "@vrtmrz/livesync-commonlib/adaptive-journal";
|
||||
import {
|
||||
defaultRemoteProviderRegistry,
|
||||
upsertRemoteConfigurationInPlace,
|
||||
type BuiltInRemoteConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import Intro from "./SetupWizard/dialogs/Intro.svelte";
|
||||
import SelectMethodNewUser from "./SetupWizard/dialogs/SelectMethodNewUser.svelte";
|
||||
@@ -20,9 +21,6 @@ import OutroNewUser from "./SetupWizard/dialogs/OutroNewUser.svelte";
|
||||
import OutroExistingUser from "./SetupWizard/dialogs/OutroExistingUser.svelte";
|
||||
import OutroAskUserMode from "./SetupWizard/dialogs/OutroAskUserMode.svelte";
|
||||
import SetupRemote from "./SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "./SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "./SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "./SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
import SetupRemoteE2EE from "./SetupWizard/dialogs/SetupRemoteE2EE.svelte";
|
||||
import { decodeSettingsFromQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
@@ -31,11 +29,8 @@ import type {
|
||||
OutroExistingUserResultType,
|
||||
OutroNewUserResultType,
|
||||
ScanQRCodeResultType,
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteE2EEResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
SetupRemoteInitialData,
|
||||
SetupRemoteResultType,
|
||||
UseSetupURIResultType,
|
||||
} from "./SetupWizard/dialogs/setupDialogTypes.ts";
|
||||
@@ -44,6 +39,8 @@ import {
|
||||
applySettingsWithScheduledInitialisation,
|
||||
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
|
||||
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
|
||||
import { builtInRemoteSetupRegistry } from "./SetupWizard/builtInRemoteSetupProviders.ts";
|
||||
import type { RemoteSetupIntent } from "./SetupWizard/RemoteSetupRegistry.ts";
|
||||
|
||||
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
@@ -75,10 +72,35 @@ export const enum UserMode {
|
||||
Update = "unknown", // Alias for Unknown for better readability
|
||||
}
|
||||
|
||||
type AdaptiveJournalIdentitySetting = {
|
||||
expectedRepositoryId?: string;
|
||||
journalFormat?: string;
|
||||
};
|
||||
|
||||
async function prepareAdaptiveRepositoryIdentityForSetup<T extends object>(
|
||||
settings: T,
|
||||
userMode: UserMode
|
||||
): Promise<T> {
|
||||
const adaptiveSettings = settings as T & AdaptiveJournalIdentitySetting;
|
||||
if (
|
||||
userMode !== UserMode.NewUser ||
|
||||
adaptiveSettings.journalFormat !== "adaptive-v1" ||
|
||||
(adaptiveSettings.expectedRepositoryId ?? "").trim() !== ""
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
return {
|
||||
...settings,
|
||||
expectedRepositoryId: await generateAdaptiveJournalRepositoryIdV1(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Manager to handle onboarding and configuration setup
|
||||
*/
|
||||
export class SetupManager extends AbstractModule {
|
||||
readonly remoteSetupRegistry = builtInRemoteSetupRegistry;
|
||||
|
||||
// /**
|
||||
// * Dialog manager for handling Svelte dialogs
|
||||
// */
|
||||
@@ -157,6 +179,81 @@ export class SetupManager extends AbstractModule {
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode);
|
||||
}
|
||||
|
||||
private setupIntent(userMode: UserMode): RemoteSetupIntent {
|
||||
if (userMode === UserMode.NewUser) return "create-or-connect";
|
||||
if (userMode === UserMode.ExistingUser) return "connect-existing";
|
||||
return "settings";
|
||||
}
|
||||
|
||||
private async selectRemoteSetupProvider(): Promise<BuiltInRemoteConfiguration["type"] | false> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType, SetupRemoteInitialData>(
|
||||
SetupRemote,
|
||||
this.remoteSetupRegistry.choices()
|
||||
);
|
||||
return method === "cancelled" ? false : method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a provider-specific dialogue without applying or storing the resulting settings.
|
||||
*
|
||||
* The settings pane uses this boundary to prepare a remote profile in its own edit buffer.
|
||||
*/
|
||||
async configureRemoteForSettings(
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
type?: BuiltInRemoteConfiguration["type"]
|
||||
): Promise<ObsidianLiveSyncSettings | false> {
|
||||
const selectedType = type ?? (await this.selectRemoteSetupProvider());
|
||||
if (selectedType === false) return false;
|
||||
|
||||
const configuration = await this.remoteSetupRegistry.open(selectedType, {
|
||||
dialogManager: this.dialogManager,
|
||||
intent: "settings",
|
||||
settings: currentSetting,
|
||||
});
|
||||
if (configuration === "cancelled") return false;
|
||||
|
||||
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||
defaultRemoteProviderRegistry.applyConfiguration(newSetting, configuration);
|
||||
return newSetting;
|
||||
}
|
||||
|
||||
private async onRemoteManualSetup(
|
||||
type: BuiltInRemoteConfiguration["type"],
|
||||
userMode: UserMode,
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate: boolean
|
||||
): Promise<boolean> {
|
||||
const configuration = await this.remoteSetupRegistry.open(type, {
|
||||
dialogManager: this.dialogManager,
|
||||
intent: this.setupIntent(userMode),
|
||||
settings: currentSetting,
|
||||
});
|
||||
if (configuration === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
|
||||
const newSetting = copySettingsForRemoteProfileUpdate(currentSetting);
|
||||
const preparedSettings = await prepareAdaptiveRepositoryIdentityForSetup(configuration.settings, userMode);
|
||||
const preparedConfiguration = {
|
||||
...configuration,
|
||||
settings: preparedSettings,
|
||||
} as BuiltInRemoteConfiguration;
|
||||
if (activate) {
|
||||
defaultRemoteProviderRegistry.applyConfiguration(newSetting, preparedConfiguration);
|
||||
} else {
|
||||
Object.assign(newSetting, preparedSettings);
|
||||
}
|
||||
|
||||
const activateForP2P = defaultRemoteProviderRegistry.supportsActivationRole(type, "p2p");
|
||||
upsertRemoteConfigurationInPlace(newSetting, type, {
|
||||
id: activateForP2P ? newSetting.P2P_ActiveRemoteConfigurationId || undefined : undefined,
|
||||
activate,
|
||||
activateForP2P,
|
||||
});
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles manual setup for CouchDB
|
||||
* @param userMode
|
||||
@@ -169,31 +266,7 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const couchConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, {
|
||||
settings: currentSetting,
|
||||
mode:
|
||||
userMode === UserMode.NewUser
|
||||
? "create-or-connect"
|
||||
: userMode === UserMode.ExistingUser
|
||||
? "connect-existing"
|
||||
: "settings",
|
||||
});
|
||||
if (couchConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...couchConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_COUCHDB;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
return await this.onRemoteManualSetup("couchdb", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,23 +281,7 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const bucketConf = await this.dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteBucketResultType,
|
||||
BucketSyncSetting
|
||||
>(SetupRemoteBucket, currentSetting);
|
||||
if (bucketConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...bucketConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
if (activate) {
|
||||
newSetting.remoteType = REMOTE_MINIO;
|
||||
}
|
||||
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
return await this.onRemoteManualSetup("s3", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,24 +296,7 @@ export class SetupManager extends AbstractModule {
|
||||
currentSetting: ObsidianLiveSyncSettings,
|
||||
activate = true
|
||||
): Promise<boolean> {
|
||||
const p2pConf = await this.dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
currentSetting
|
||||
);
|
||||
if (p2pConf === "cancelled") {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
const newSetting = {
|
||||
...copySettingsForRemoteProfileUpdate(currentSetting),
|
||||
...p2pConf,
|
||||
} as ObsidianLiveSyncSettings;
|
||||
upsertRemoteConfigurationInPlace(newSetting, "p2p", {
|
||||
id: newSetting.P2P_ActiveRemoteConfigurationId || undefined,
|
||||
activate,
|
||||
activateForP2P: true,
|
||||
});
|
||||
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
|
||||
return await this.onRemoteManualSetup("p2p", userMode, currentSetting, activate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,21 +350,15 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns
|
||||
*/
|
||||
async onSelectServer(currentSetting: ObsidianLiveSyncSettings, userMode: UserMode): Promise<boolean> {
|
||||
const method = await this.dialogManager.openWithExplicitCancel<SetupRemoteResultType>(SetupRemote);
|
||||
if (method === "couchdb") {
|
||||
return await this.onCouchDBManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "bucket") {
|
||||
return await this.onBucketManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "p2p") {
|
||||
return await this.onP2PManualSetup(userMode, currentSetting, true);
|
||||
} else if (method === "cancelled") {
|
||||
const method = await this.selectRemoteSetupProvider();
|
||||
if (method === false) {
|
||||
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
|
||||
if (userMode !== UserMode.Unknown) {
|
||||
return await this.onOnboard(userMode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Should not reach here.
|
||||
return false;
|
||||
return await this.onRemoteManualSetup(method, userMode, currentSetting, true);
|
||||
}
|
||||
/**
|
||||
* Confirms and applies settings obtained from the wizard
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
@@ -407,6 +408,9 @@ describe("SetupManager", () => {
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
@@ -419,6 +423,97 @@ describe("SetupManager", () => {
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.name).toBe("S3 notes");
|
||||
expect(activeProfile?.uri).toContain("sls+s3://key:secret@storage.example");
|
||||
expect(activeProfile?.uri).toContain("journalFormat=adaptive-v1");
|
||||
expect(activeProfile?.uri).toContain("packReadPolicy=range");
|
||||
expect(activeProfile?.uri).toContain("expectedRepositoryId=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
|
||||
});
|
||||
|
||||
it("uses the registered provider choices when configuring a Settings edit buffer", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
const bucketSettings = {
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("s3").mockResolvedValueOnce(bucketSettings);
|
||||
|
||||
const nextSettings = await manager.configureRemoteForSettings(setting.currentSettings());
|
||||
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "couchdb" }),
|
||||
expect.objectContaining({ type: "s3" }),
|
||||
expect.objectContaining({ type: "p2p" }),
|
||||
])
|
||||
);
|
||||
expect(dialogManager.openWithExplicitCancel).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
setting.currentSettings()
|
||||
);
|
||||
expect(nextSettings).toEqual(expect.objectContaining({ ...bucketSettings, remoteType: REMOTE_MINIO }));
|
||||
});
|
||||
|
||||
it("preselects a repository identity during fresh Adaptive Object Storage onboarding", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onBucketManualSetup(UserMode.NewUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.expectedRepositoryId).toMatch(/^[A-Za-z0-9_-]{43}$/u);
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.uri).toContain(`expectedRepositoryId=${current.expectedRepositoryId}`);
|
||||
});
|
||||
|
||||
it("leaves an existing-device Adaptive Object Storage attachment on trust on first use", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce({
|
||||
endpoint: "https://storage.example",
|
||||
accessKey: "key",
|
||||
secretKey: "secret",
|
||||
bucket: "notes",
|
||||
region: "auto",
|
||||
bucketPrefix: "",
|
||||
useCustomRequestHandler: false,
|
||||
bucketCustomHeaders: "",
|
||||
forcePathStyle: true,
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
})
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
await manager.onBucketManualSetup(UserMode.ExistingUser, setting.currentSettings());
|
||||
|
||||
const current = setting.currentSettings();
|
||||
expect(current.expectedRepositoryId).toBe("");
|
||||
const activeProfile = current.remoteConfigurations[current.activeConfigurationId];
|
||||
expect(activeProfile?.uri).not.toContain("expectedRepositoryId=");
|
||||
});
|
||||
|
||||
it("creates and selects a P2P profile during fresh manual onboarding", async () => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type {
|
||||
BuiltInRemoteConfiguration,
|
||||
RemoteProviderConfiguration,
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
|
||||
export type RemoteSetupIntent = "create-or-connect" | "connect-existing" | "settings";
|
||||
|
||||
export interface RemoteSetupDialogManager {
|
||||
openWithExplicitCancel<TResult, TInitial = TResult>(
|
||||
component: ComponentHasResult<TResult, TInitial>,
|
||||
initialData?: TInitial
|
||||
): Promise<TResult>;
|
||||
}
|
||||
|
||||
export interface RemoteSetupContext {
|
||||
readonly dialogManager: RemoteSetupDialogManager;
|
||||
readonly intent: RemoteSetupIntent;
|
||||
readonly settings: ObsidianLiveSyncSettings;
|
||||
}
|
||||
|
||||
export interface RemoteSetupChoice<TType extends string = string> {
|
||||
readonly description: string;
|
||||
readonly proceedTitle: string;
|
||||
readonly title: string;
|
||||
readonly type: TType;
|
||||
}
|
||||
|
||||
export interface RemoteSetupProviderDescriptor<
|
||||
TConfiguration extends RemoteProviderConfiguration = RemoteProviderConfiguration,
|
||||
> {
|
||||
readonly type: TConfiguration["type"];
|
||||
choice(): Omit<RemoteSetupChoice<TConfiguration["type"]>, "type">;
|
||||
open(context: RemoteSetupContext): Promise<TConfiguration | "cancelled">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-side presentation for the remote providers compiled into this plug-in.
|
||||
*
|
||||
* Connection semantics remain in Commonlib's remote provider registry. This registry only owns
|
||||
* the Obsidian-specific choice text and setup dialogue for each provider.
|
||||
*/
|
||||
export class RemoteSetupRegistry<TConfiguration extends RemoteProviderConfiguration = BuiltInRemoteConfiguration> {
|
||||
private readonly providersByType = new Map<string, RemoteSetupProviderDescriptor<TConfiguration>>();
|
||||
private frozen = false;
|
||||
|
||||
register<TProviderConfiguration extends TConfiguration>(
|
||||
descriptor: RemoteSetupProviderDescriptor<TProviderConfiguration>
|
||||
): this {
|
||||
if (this.frozen) throw new Error("The remote setup registry is frozen");
|
||||
if (this.providersByType.has(descriptor.type)) {
|
||||
throw new Error(`Remote setup provider '${descriptor.type}' is already registered`);
|
||||
}
|
||||
this.providersByType.set(descriptor.type, descriptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
freeze(): this {
|
||||
this.frozen = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
isFrozen(): boolean {
|
||||
return this.frozen;
|
||||
}
|
||||
|
||||
choices(): RemoteSetupChoice<TConfiguration["type"]>[] {
|
||||
return [...this.providersByType.values()].map((provider) => ({
|
||||
...provider.choice(),
|
||||
type: provider.type,
|
||||
}));
|
||||
}
|
||||
|
||||
has(type: string): type is TConfiguration["type"] {
|
||||
return this.providersByType.has(type);
|
||||
}
|
||||
|
||||
open(type: TConfiguration["type"], context: RemoteSetupContext): Promise<TConfiguration | "cancelled"> {
|
||||
const provider = this.providersByType.get(type);
|
||||
if (!provider) throw new Error(`Unsupported remote setup provider: ${type}`);
|
||||
return provider.open(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||
|
||||
type TestConfiguration =
|
||||
| { type: "alpha"; settings: { value: string } }
|
||||
| { type: "beta"; settings: { enabled: boolean } };
|
||||
|
||||
function alphaProvider(
|
||||
open: RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>>["open"] = async () => ({
|
||||
type: "alpha",
|
||||
settings: { value: "configured" },
|
||||
})
|
||||
): RemoteSetupProviderDescriptor<Extract<TestConfiguration, { type: "alpha" }>> {
|
||||
return {
|
||||
type: "alpha",
|
||||
choice: () => ({
|
||||
title: "Alpha",
|
||||
description: "Alpha description",
|
||||
proceedTitle: "Configure Alpha",
|
||||
}),
|
||||
open,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RemoteSetupRegistry", () => {
|
||||
it("preserves registration order and dispatches setup through the selected provider", async () => {
|
||||
const openAlpha = vi.fn(alphaProvider().open);
|
||||
const registry = new RemoteSetupRegistry<TestConfiguration>()
|
||||
.register(alphaProvider(openAlpha))
|
||||
.register({
|
||||
type: "beta",
|
||||
choice: () => ({
|
||||
title: "Beta",
|
||||
description: "Beta description",
|
||||
proceedTitle: "Configure Beta",
|
||||
}),
|
||||
open: async () => ({ type: "beta", settings: { enabled: true } }),
|
||||
})
|
||||
.freeze();
|
||||
const context = {
|
||||
dialogManager: { openWithExplicitCancel: vi.fn() },
|
||||
intent: "settings" as const,
|
||||
settings: {} as never,
|
||||
};
|
||||
|
||||
expect(registry.choices().map((choice) => choice.type)).toEqual(["alpha", "beta"]);
|
||||
await expect(registry.open("alpha", context)).resolves.toEqual({
|
||||
type: "alpha",
|
||||
settings: { value: "configured" },
|
||||
});
|
||||
expect(openAlpha).toHaveBeenCalledWith(context);
|
||||
});
|
||||
|
||||
it("rejects duplicate and late registrations", () => {
|
||||
const registry = new RemoteSetupRegistry<TestConfiguration>().register(alphaProvider());
|
||||
|
||||
expect(() => registry.register(alphaProvider())).toThrow("already registered");
|
||||
|
||||
registry.freeze();
|
||||
expect(registry.isFrozen()).toBe(true);
|
||||
expect(() =>
|
||||
registry.register({
|
||||
type: "beta",
|
||||
choice: () => ({ title: "Beta", description: "", proceedTitle: "" }),
|
||||
open: async () => "cancelled",
|
||||
})
|
||||
).toThrow("frozen");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { BucketSyncSetting, P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { defaultRemoteProviderRegistry } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
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 type {
|
||||
SetupRemoteBucketResultType,
|
||||
SetupRemoteCouchDBInitialData,
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteP2PResultType,
|
||||
} from "./dialogs/setupDialogTypes";
|
||||
import { RemoteSetupRegistry, type RemoteSetupProviderDescriptor } from "./RemoteSetupRegistry";
|
||||
|
||||
type ConfigurationOf<TType extends BuiltInRemoteConfiguration["type"]> = Extract<
|
||||
BuiltInRemoteConfiguration,
|
||||
{ type: TType }
|
||||
>;
|
||||
|
||||
function assertSemanticProvider(type: BuiltInRemoteConfiguration["type"]): void {
|
||||
const isRegistered = defaultRemoteProviderRegistry.providerSummaries().some((provider) => provider.type === type);
|
||||
if (!isRegistered) throw new Error(`Remote setup provider '${type}' has no Commonlib provider`);
|
||||
}
|
||||
|
||||
export function useCouchDBRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"couchdb">> = {
|
||||
type: "couchdb",
|
||||
choice: () => ({
|
||||
title: "CouchDB",
|
||||
description: translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc"),
|
||||
proceedTitle: translateMessage("Continue to CouchDB setup"),
|
||||
}),
|
||||
open: async ({ dialogManager, intent, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<
|
||||
SetupRemoteCouchDBResultType,
|
||||
SetupRemoteCouchDBInitialData
|
||||
>(SetupRemoteCouchDB, { settings, mode: intent });
|
||||
return result === "cancelled" ? result : { type: "couchdb", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function useS3RemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"s3">> = {
|
||||
type: "s3",
|
||||
choice: () => ({
|
||||
title: translateMessage("Ui.SetupWizard.SetupRemote.BucketOption"),
|
||||
description: translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc"),
|
||||
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket"),
|
||||
}),
|
||||
open: async ({ dialogManager, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<SetupRemoteBucketResultType, BucketSyncSetting>(
|
||||
SetupRemoteBucket,
|
||||
settings
|
||||
);
|
||||
return result === "cancelled" ? result : { type: "s3", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function useP2PRemoteSetup(
|
||||
registry: RemoteSetupRegistry<BuiltInRemoteConfiguration>
|
||||
): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const descriptor: RemoteSetupProviderDescriptor<ConfigurationOf<"p2p">> = {
|
||||
type: "p2p",
|
||||
choice: () => ({
|
||||
title: translateMessage("Ui.SetupWizard.SetupRemote.P2POption"),
|
||||
description: translateMessage(
|
||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||
),
|
||||
proceedTitle: translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P"),
|
||||
}),
|
||||
open: async ({ dialogManager, settings }) => {
|
||||
const result = await dialogManager.openWithExplicitCancel<SetupRemoteP2PResultType, P2PSyncSetting>(
|
||||
SetupRemoteP2P,
|
||||
settings
|
||||
);
|
||||
return result === "cancelled" ? result : { type: "p2p", settings: result };
|
||||
},
|
||||
};
|
||||
assertSemanticProvider(descriptor.type);
|
||||
return registry.register(descriptor);
|
||||
}
|
||||
|
||||
export function createBuiltInRemoteSetupRegistry(): RemoteSetupRegistry<BuiltInRemoteConfiguration> {
|
||||
const registry = new RemoteSetupRegistry<BuiltInRemoteConfiguration>();
|
||||
useCouchDBRemoteSetup(registry);
|
||||
useS3RemoteSetup(registry);
|
||||
useP2PRemoteSetup(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export const builtInRemoteSetupRegistry = createBuiltInRemoteSetupRegistry().freeze();
|
||||
@@ -7,58 +7,33 @@
|
||||
import Instruction from "@/modules/services/LiveSyncUI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import {
|
||||
TYPE_COUCHDB,
|
||||
TYPE_BUCKET,
|
||||
TYPE_P2P,
|
||||
TYPE_CANCELLED,
|
||||
type SetupRemoteResultType,
|
||||
} from "./setupDialogTypes";
|
||||
import { onMount } from "svelte";
|
||||
import { type GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
|
||||
import { TYPE_CANCELLED, type SetupRemoteInitialData, type SetupRemoteResultType } from "./setupDialogTypes";
|
||||
|
||||
type Props = {
|
||||
setResult: (result: SetupRemoteResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
type Props = GuestDialogProps<SetupRemoteResultType, SetupRemoteInitialData>;
|
||||
const { setResult, getInitialData }: Props = $props();
|
||||
let choices = $state<SetupRemoteInitialData>([]);
|
||||
let userType = $state<SetupRemoteResultType>(TYPE_CANCELLED);
|
||||
let proceedTitle = $derived.by(() => {
|
||||
if (userType === TYPE_COUCHDB) {
|
||||
return translateMessage("Continue to CouchDB setup");
|
||||
} else if (userType === TYPE_BUCKET) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedBucket");
|
||||
} else if (userType === TYPE_P2P) {
|
||||
return translateMessage("Ui.SetupWizard.SetupRemote.ProceedP2P");
|
||||
} else {
|
||||
return translateMessage("Please select an option to proceed");
|
||||
}
|
||||
});
|
||||
const canProceed = $derived.by(() => {
|
||||
return userType === TYPE_COUCHDB || userType === TYPE_BUCKET || userType === TYPE_P2P;
|
||||
onMount(() => {
|
||||
choices = getInitialData?.() ?? [];
|
||||
});
|
||||
const selectedChoice = $derived(choices.find((choice) => choice.type === userType));
|
||||
const proceedTitle = $derived(
|
||||
selectedChoice?.proceedTitle ?? translateMessage("Please select an option to proceed")
|
||||
);
|
||||
const canProceed = $derived(selectedChoice !== undefined);
|
||||
</script>
|
||||
|
||||
<DialogHeader title={translateMessage("Ui.SetupWizard.SetupRemote.Title")} />
|
||||
<Instruction>
|
||||
<Question>{translateMessage("Ui.SetupWizard.SetupRemote.Guidance")}</Question>
|
||||
<Options>
|
||||
<Option selectedValue={TYPE_COUCHDB} title="CouchDB" bind:value={userType}>
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.CouchDbOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_BUCKET}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.BucketOption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage("Ui.SetupWizard.SetupRemote.BucketOptionDesc")}
|
||||
</Option>
|
||||
<Option
|
||||
selectedValue={TYPE_P2P}
|
||||
title={translateMessage("Ui.SetupWizard.SetupRemote.P2POption")}
|
||||
bind:value={userType}
|
||||
>
|
||||
{translateMessage(
|
||||
"No central data-storage server is required, but a signalling relay is required for peer discovery. Both devices must be online at the same time. Vault data travels through the encrypted P2P connection, not through the signalling relay. Some features may be limited."
|
||||
)}
|
||||
</Option>
|
||||
{#each choices as choice (choice.type)}
|
||||
<Option selectedValue={choice.type} title={choice.title} bind:value={userType}>
|
||||
{choice.description}
|
||||
</Option>
|
||||
{/each}
|
||||
</Options>
|
||||
</Instruction>
|
||||
<UserDecisions>
|
||||
|
||||
@@ -20,10 +20,16 @@
|
||||
import { copyTo, pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { TYPE_CANCELLED, type SetupRemoteBucketResultType } from "./setupDialogTypes";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { normaliseS3JournalSettings } from "./s3JournalSettings";
|
||||
|
||||
const default_setting = pickBucketSyncSettings(DEFAULT_SETTINGS);
|
||||
|
||||
let syncSetting = $state<BucketSyncSetting>({ ...default_setting });
|
||||
let syncSetting = $state<BucketSyncSetting>({
|
||||
...default_setting,
|
||||
expectedRepositoryId: default_setting.expectedRepositoryId ?? "",
|
||||
journalFormat: default_setting.journalFormat ?? "opaque-v1",
|
||||
packReadPolicy: default_setting.packReadPolicy ?? "whole-pack",
|
||||
});
|
||||
|
||||
type Props = GuestDialogProps<SetupRemoteBucketResultType, BucketSyncSetting>;
|
||||
|
||||
@@ -58,11 +64,10 @@
|
||||
isEndpointSupplied
|
||||
);
|
||||
});
|
||||
const isAdaptive = $derived(syncSetting.journalFormat === "adaptive-v1");
|
||||
|
||||
function generateSetting() {
|
||||
const connSetting: BucketSyncSetting = {
|
||||
...syncSetting,
|
||||
};
|
||||
const connSetting = normaliseS3JournalSettings(syncSetting);
|
||||
const trialSettings: BucketSyncSetting = {
|
||||
...connSetting,
|
||||
};
|
||||
@@ -115,8 +120,13 @@
|
||||
}
|
||||
}
|
||||
function commit() {
|
||||
const setting = pickBucketSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
error = "";
|
||||
try {
|
||||
const setting = pickBucketSyncSettings(generateSetting());
|
||||
setResult(setting);
|
||||
} catch (e) {
|
||||
error = translateMessage("Invalid Object Storage settings: ${reason}", { reason: `${e}` });
|
||||
}
|
||||
}
|
||||
function cancel() {
|
||||
setResult(TYPE_CANCELLED);
|
||||
@@ -220,6 +230,30 @@
|
||||
</InfoNote>
|
||||
|
||||
<ExtraItems title={translateMessage("Advanced Settings")}>
|
||||
<InputRow label={translateMessage("Journal data format")}>
|
||||
<select name="s3-journal-format" bind:value={syncSetting.journalFormat}>
|
||||
<option value="opaque-v1">{translateMessage("Opaque Journal (current format)")}</option>
|
||||
<option value="adaptive-v1">{translateMessage("Adaptive Journal (experimental)")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote warning visible={isAdaptive}>
|
||||
{translateMessage(
|
||||
"Adaptive Journal uses immutable objects and a separate remote format. Existing Opaque Journal data is not migrated or read. Rebuild the remote when changing formats."
|
||||
)}
|
||||
</InfoNote>
|
||||
{#if isAdaptive}
|
||||
<InputRow label={translateMessage("Pack retrieval")}>
|
||||
<select name="s3-pack-read-policy" bind:value={syncSetting.packReadPolicy}>
|
||||
<option value="whole-pack">{translateMessage("Download complete Packs")}</option>
|
||||
<option value="range">{translateMessage("Use S3 Range requests")}</option>
|
||||
</select>
|
||||
</InputRow>
|
||||
<InfoNote>
|
||||
{translateMessage(
|
||||
"Complete Pack reads favour throughput. Range reads can reduce transferred bytes. The connection test verifies exact Range support on this endpoint, and synchronisation refuses an unsupported selection before writing."
|
||||
)}
|
||||
</InfoNote>
|
||||
{/if}
|
||||
<InputRow label={translateMessage("Custom Headers")}>
|
||||
<textarea
|
||||
name="bucket-custom-headers"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DEFAULT_SETTINGS, REMOTE_MINIO, type BucketSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { journalProtocolConfigurationForSettings } from "@vrtmrz/livesync-commonlib/journal-storage";
|
||||
|
||||
export function normaliseS3JournalSettings(settings: BucketSyncSetting): BucketSyncSetting {
|
||||
const journalFormat = settings.journalFormat ?? "opaque-v1";
|
||||
const candidate: BucketSyncSetting = {
|
||||
...settings,
|
||||
bucket: settings.bucket.trim(),
|
||||
bucketPrefix: settings.bucketPrefix.trim(),
|
||||
endpoint: settings.endpoint.trim(),
|
||||
expectedRepositoryId: journalFormat === "adaptive-v1" ? (settings.expectedRepositoryId ?? "").trim() : "",
|
||||
journalFormat,
|
||||
packReadPolicy: journalFormat === "adaptive-v1" ? (settings.packReadPolicy ?? "whole-pack") : "whole-pack",
|
||||
region: settings.region.trim(),
|
||||
};
|
||||
const protocol = journalProtocolConfigurationForSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
...candidate,
|
||||
remoteType: REMOTE_MINIO,
|
||||
});
|
||||
return {
|
||||
...candidate,
|
||||
...protocol,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, type BucketSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { pickBucketSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { normaliseS3JournalSettings } from "./s3JournalSettings";
|
||||
|
||||
function settings(overrides: Partial<BucketSyncSetting> = {}): BucketSyncSetting {
|
||||
return {
|
||||
...pickBucketSyncSettings(DEFAULT_SETTINGS),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normaliseS3JournalSettings", () => {
|
||||
it("retains and validates Adaptive repository options", () => {
|
||||
const repositoryId = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
const result = normaliseS3JournalSettings(
|
||||
settings({
|
||||
bucket: " vault ",
|
||||
bucketPrefix: " journals/ ",
|
||||
endpoint: " https://storage.example ",
|
||||
expectedRepositoryId: ` ${repositoryId} `,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
region: " auto ",
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
bucket: "vault",
|
||||
bucketPrefix: "journals/",
|
||||
endpoint: "https://storage.example",
|
||||
expectedRepositoryId: repositoryId,
|
||||
journalFormat: "adaptive-v1",
|
||||
packReadPolicy: "range",
|
||||
region: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses conservative Opaque defaults for older profiles", () => {
|
||||
const legacy = settings();
|
||||
delete legacy.expectedRepositoryId;
|
||||
delete legacy.journalFormat;
|
||||
delete legacy.packReadPolicy;
|
||||
|
||||
expect(normaliseS3JournalSettings(legacy)).toMatchObject({
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears Adaptive-only options when Opaque Journal is selected", () => {
|
||||
expect(
|
||||
normaliseS3JournalSettings(
|
||||
settings({
|
||||
expectedRepositoryId: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "range",
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
expectedRepositoryId: "",
|
||||
journalFormat: "opaque-v1",
|
||||
packReadPolicy: "whole-pack",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-canonical expected repository ID", () => {
|
||||
expect(() =>
|
||||
normaliseS3JournalSettings(
|
||||
settings({
|
||||
expectedRepositoryId: "not-a-repository-id",
|
||||
journalFormat: "adaptive-v1",
|
||||
})
|
||||
)
|
||||
).toThrow("expectedRepositoryId must be a canonical base64url-encoded 32-byte value");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ObsidianLiveSyncSettings,
|
||||
P2PConnectionInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type";
|
||||
import type { BuiltInRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import type { RemoteSetupChoice } from "@/modules/features/SetupWizard/RemoteSetupRegistry";
|
||||
|
||||
export const TYPE_IDENTICAL = "identical";
|
||||
export const TYPE_INDEPENDENT = "independent";
|
||||
@@ -38,7 +40,7 @@ export const TYPE_CLOSE = "close";
|
||||
|
||||
// SetupRemote
|
||||
export const TYPE_COUCHDB = "couchdb";
|
||||
export const TYPE_BUCKET = "bucket";
|
||||
export const TYPE_BUCKET = "s3";
|
||||
export const TYPE_P2P = "p2p";
|
||||
|
||||
export type ResultTypeVault =
|
||||
@@ -93,7 +95,8 @@ export type SelectMethodExistingResultType =
|
||||
| typeof TYPE_CONFIGURE_MANUALLY
|
||||
| typeof TYPE_CANCELLED;
|
||||
|
||||
export type SetupRemoteResultType = typeof TYPE_COUCHDB | typeof TYPE_BUCKET | typeof TYPE_P2P | typeof TYPE_CANCELLED;
|
||||
export type SetupRemoteResultType = BuiltInRemoteConfiguration["type"] | typeof TYPE_CANCELLED;
|
||||
export type SetupRemoteInitialData = RemoteSetupChoice<BuiltInRemoteConfiguration["type"]>[];
|
||||
|
||||
export type UseSetupURIResultType = typeof TYPE_CANCELLED | ObsidianLiveSyncSettings;
|
||||
|
||||
|
||||
@@ -147,14 +147,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
: req instanceof Request && typeof req.method === "string"
|
||||
? req.method
|
||||
: "GET";
|
||||
if (typeof req !== "string") {
|
||||
if (opts?.body) {
|
||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
||||
} else if (req.body) {
|
||||
body = await new Response(req.body).arrayBuffer();
|
||||
}
|
||||
} else {
|
||||
body = opts?.body as string;
|
||||
const suppliedBody = opts?.body ?? (req instanceof Request ? req.body : undefined);
|
||||
if (suppliedBody !== undefined && suppliedBody !== null) {
|
||||
body = typeof suppliedBody === "string" ? suppliedBody : await new Response(suppliedBody).arrayBuffer();
|
||||
}
|
||||
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
||||
|
||||
@@ -192,7 +187,9 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
contentType: contentType,
|
||||
};
|
||||
const r = await requestUrl({ ...requestParam, throw: false });
|
||||
return new Response(r.arrayBuffer, {
|
||||
const responseHasNoBody =
|
||||
method.toUpperCase() === "HEAD" || r.status === 204 || r.status === 205 || r.status === 304;
|
||||
return new Response(responseHasNoBody ? null : r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
|
||||
@@ -4,16 +4,17 @@ const mocks = vi.hoisted(() => ({
|
||||
platform: {
|
||||
isMobile: false,
|
||||
},
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/deps", () => ({
|
||||
Platform: mocks.platform,
|
||||
requestUrl: vi.fn(),
|
||||
requestUrl: mocks.requestUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({
|
||||
@@ -65,3 +66,41 @@ describe("ObsidianAPIService.showWindowOnRight", () => {
|
||||
expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ObsidianAPIService.nativeFetch", () => {
|
||||
it("normalises a typed-array body when the request URL is a string", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
arrayBuffer: new Uint8Array([9, 8, 7]).buffer,
|
||||
headers: { etag: '"created"' },
|
||||
status: 201,
|
||||
});
|
||||
const source = new Uint8Array([0, 1, 2, 3, 4]);
|
||||
const body = source.subarray(1, 4);
|
||||
|
||||
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||
body: body as unknown as BodyInit,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const request = mocks.requestUrl.mock.calls[0][0] as { body?: unknown };
|
||||
expect(request.body).toBeInstanceOf(ArrayBuffer);
|
||||
expect([...new Uint8Array(request.body as ArrayBuffer)]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("constructs a bodyless response for a successful DELETE", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {},
|
||||
status: 204,
|
||||
});
|
||||
|
||||
const response = await createService({}).nativeFetch("http://127.0.0.1:8088/dav/probe.bin", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,12 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Improved
|
||||
|
||||
- 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.
|
||||
|
||||
### P2P and experimental browser applications
|
||||
|
||||
#### Improved
|
||||
|
||||
Reference in New Issue
Block a user