feat: expose adaptive journal options for S3

This commit is contained in:
vorotamoroz
2026-07-31 19:24:40 +00:00
parent 53d4bd527e
commit b7da78ce45
10 changed files with 227 additions and 6 deletions
@@ -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");
});
});