Preserve legacy settings across same-profile upgrades

This commit is contained in:
vorotamoroz
2026-07-22 06:21:54 +00:00
parent c669f64933
commit aad0e56f09
22 changed files with 2258 additions and 167 deletions
+38
View File
@@ -26,6 +26,22 @@ export type CouchDbAllDocsResponse = {
}>;
};
export type CouchDbLocalDocsResponse = {
rows: Array<{
id: string;
key: string;
value: { rev: string };
doc?: CouchDbDocument;
}>;
};
export type CouchDbDatabaseInfo = {
db_name: string;
doc_count: number;
doc_del_count: number;
update_seq: number | string;
};
function parseEnvFile(content: string): Record<string, string> {
const entries = content
.split(/\r?\n/u)
@@ -170,6 +186,28 @@ export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string)
return (await response.json()) as CouchDbAllDocsResponse;
}
export async function fetchCouchDbLocalDocs(config: CouchDbConfig, dbName: string): Promise<CouchDbLocalDocsResponse> {
const response = await fetch(databaseUrl(config, dbName, "/_local_docs?include_docs=true"), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(
`Failed to read CouchDB local documents from ${dbName}. HTTP ${response.status}: ${await response.text()}`
);
}
return (await response.json()) as CouchDbLocalDocsResponse;
}
export async function fetchCouchDbDatabaseInfo(config: CouchDbConfig, dbName: string): Promise<CouchDbDatabaseInfo> {
const response = await fetch(databaseUrl(config, dbName), {
headers: { authorization: authHeader(config) },
});
if (!response.ok) {
throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`);
}
return (await response.json()) as CouchDbDatabaseInfo;
}
export async function waitForCouchDbDocs(
config: CouchDbConfig,
dbName: string,
+20 -13
View File
@@ -287,19 +287,7 @@ export async function configureObjectStorage(
settings: ObjectStorageConfig & { bucketPrefix: string },
overrides: Record<string, unknown> = {}
): Promise<ConfiguredSettings> {
const nextSettings = {
remoteType: "MINIO",
endpoint: settings.endpoint,
accessKey: settings.accessKey,
secretKey: settings.secretKey,
bucket: settings.bucket,
region: settings.region,
forcePathStyle: settings.forcePathStyle,
bucketPrefix: settings.bucketPrefix,
bucketCustomHeaders: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
const nextSettings = createE2eObjectStoragePluginData(settings, overrides);
return await evalObsidianJson<ConfiguredSettings>(
cliBinary,
[
@@ -328,6 +316,25 @@ export async function configureObjectStorage(
);
}
export function createE2eObjectStoragePluginData(
settings: ObjectStorageConfig & { bucketPrefix: string },
overrides: Record<string, unknown> = {}
): Record<string, unknown> {
return {
remoteType: "MINIO",
endpoint: settings.endpoint,
accessKey: settings.accessKey,
secretKey: settings.secretKey,
bucket: settings.bucket,
region: settings.region,
forcePathStyle: settings.forcePathStyle,
bucketPrefix: settings.bucketPrefix,
bucketCustomHeaders: "",
...E2E_PREFERRED_SETTINGS,
...overrides,
};
}
export async function waitForLiveSyncCoreReady(
cliBinary: string,
env: NodeJS.ProcessEnv,
+17
View File
@@ -1,6 +1,7 @@
import {
CreateBucketCommand,
DeleteObjectsCommand,
GetObjectCommand,
ListObjectsV2Command,
S3Client,
type _Object,
@@ -120,6 +121,22 @@ export async function listObjectStorageObjects(config: ObjectStorageConfig, pref
}
}
export async function readObjectStorageObject(config: ObjectStorageConfig, key: string): Promise<Uint8Array> {
const client = createObjectStorageClient(config);
try {
const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
if (!response.Body) throw new Error(`Object Storage returned an empty body for ${key}.`);
return await response.Body.transformToByteArray();
} finally {
client.destroy();
}
}
export async function readObjectStorageJson<T>(config: ObjectStorageConfig, key: string): Promise<T> {
const bytes = await readObjectStorageObject(config, key);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
export async function deleteObjectStoragePrefix(config: ObjectStorageConfig, prefix: string): Promise<void> {
const client = createObjectStorageClient(config);
try {
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ensurePinnedReleaseArtifact,
type PinnedPluginRelease,
} from "./releaseArtifact.ts";
const temporaryDirectories: string[] = [];
function sha256(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
function fixtureRelease(contents: Record<"main.js" | "manifest.json" | "styles.css", string>): PinnedPluginRelease {
return {
pluginId: "fixture-plugin",
version: "1.2.3",
files: (Object.keys(contents) as Array<keyof typeof contents>).map((name) => ({
name,
url: `https://example.invalid/${name}`,
sha256: sha256(contents[name]),
})),
};
}
afterEach(async () => {
for (const path of temporaryDirectories.splice(0)) {
await rm(path, { recursive: true, force: true });
}
});
describe("pinned plug-in release artefacts", () => {
it("downloads, verifies, and reuses an immutable release cache", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-"));
temporaryDirectories.push(root);
const contents = {
"main.js": "console.log('fixture');\n",
"manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n',
"styles.css": ".fixture {}\n",
};
const release = fixtureRelease(contents);
const fetchImplementation = vi.fn(async (input: string | URL | Request) => {
const name = new URL(String(input)).pathname.split("/").pop() as keyof typeof contents;
return new Response(contents[name], { status: 200 });
}) as unknown as typeof fetch;
await expect(
ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation })
).resolves.toBe(root);
await expect(readFile(join(root, "main.js"), "utf8")).resolves.toBe(contents["main.js"]);
expect(fetchImplementation).toHaveBeenCalledTimes(3);
await ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation });
expect(fetchImplementation).toHaveBeenCalledTimes(3);
});
it("rejects a downloaded file before it enters the release cache when its checksum differs", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-"));
temporaryDirectories.push(root);
const contents = {
"main.js": "expected\n",
"manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n',
"styles.css": ".fixture {}\n",
};
const release = fixtureRelease(contents);
const fetchImplementation = vi.fn(async () => new Response("tampered\n", { status: 200 })) as unknown as typeof fetch;
await expect(
ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation })
).rejects.toThrow("checksum mismatch");
await expect(readFile(join(root, "main.js"))).rejects.toMatchObject({ code: "ENOENT" });
});
});
+128
View File
@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
export type PinnedReleaseArtifactFile = {
name: "main.js" | "manifest.json" | "styles.css";
url: string;
sha256: string;
};
export type PinnedPluginRelease = {
pluginId: string;
version: string;
files: readonly PinnedReleaseArtifactFile[];
};
export type EnsurePinnedReleaseArtifactOptions = {
artifactRoot?: string;
fetchImplementation?: typeof fetch;
};
export const UPGRADE_SOURCE_RELEASE: PinnedPluginRelease = {
pluginId: "obsidian-livesync",
version: "0.25.83",
files: [
{
name: "main.js",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/main.js",
sha256: "5e57f990635ab0cf2ff3879f3c6cb91ddfdbc146958d33d1e5d21f1869dff6a4",
},
{
name: "manifest.json",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/manifest.json",
sha256: "4944f5665c94bcbb58db0e3708ec2bd8ee36118791271c01d085668876dc8ba6",
},
{
name: "styles.css",
url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/styles.css",
sha256: "37d31798186d7e97ea979e6d2aae8021ea1ac1df2c3b9d2b03dce269959c27f3",
},
],
};
function digest(content: Uint8Array<ArrayBuffer>): string {
return createHash("sha256").update(content).digest("hex");
}
function assertDigest(file: PinnedReleaseArtifactFile, content: Uint8Array<ArrayBuffer>): void {
const actual = digest(content);
if (actual !== file.sha256) {
throw new Error(
`Release artefact checksum mismatch for ${file.name}. Expected ${file.sha256}, received ${actual}.`
);
}
}
async function readCachedFile(
path: string,
file: PinnedReleaseArtifactFile
): Promise<Uint8Array<ArrayBuffer> | undefined> {
try {
const content = new Uint8Array(await readFile(path));
assertDigest(file, content);
return content;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
async function downloadVerifiedFile(
root: string,
file: PinnedReleaseArtifactFile,
fetchImplementation: typeof fetch
): Promise<Uint8Array<ArrayBuffer>> {
const path = join(root, file.name);
const cached = await readCachedFile(path, file);
if (cached) return cached;
const response = await fetchImplementation(file.url, { redirect: "follow" });
if (!response.ok) {
throw new Error(`Could not download ${file.url}. HTTP ${response.status}: ${await response.text()}`);
}
const content = new Uint8Array(await response.arrayBuffer());
assertDigest(file, content);
const temporaryPath = `${path}.download-${process.pid}-${Date.now()}`;
try {
await writeFile(temporaryPath, content, { flag: "wx" });
await rename(temporaryPath, path);
} finally {
await rm(temporaryPath, { force: true });
}
return content;
}
/**
* Materialise one immutable published plug-in release in the ignored E2E cache.
*
* Existing files are always verified before use. A mismatched cache is left in
* place for inspection and must be removed explicitly by the operator.
*/
export async function ensurePinnedReleaseArtifact(
release: PinnedPluginRelease = UPGRADE_SOURCE_RELEASE,
options: EnsurePinnedReleaseArtifactOptions = {}
): Promise<string> {
const root = resolve(
options.artifactRoot ??
process.env.E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT?.trim() ??
join("_testdata", "releases", release.pluginId, release.version)
);
await mkdir(root, { recursive: true });
const fetched = new Map<string, Uint8Array<ArrayBuffer>>();
for (const file of release.files) {
fetched.set(file.name, await downloadVerifiedFile(root, file, options.fetchImplementation ?? fetch));
}
const manifestBytes = fetched.get("manifest.json");
if (!manifestBytes) throw new Error("The pinned release does not define manifest.json.");
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as { id?: unknown; version?: unknown };
if (manifest.id !== release.pluginId || manifest.version !== release.version) {
throw new Error(
`Release manifest identity mismatch. Expected ${release.pluginId}@${release.version}, received ${String(manifest.id)}@${String(manifest.version)}.`
);
}
return root;
}
+55
View File
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startObsidianPluginSession } from "@vrtmrz/obsidian-test-session";
import {
startObsidianLiveSyncSession,
type StartObsidianLiveSyncSessionOptions,
} from "./session.ts";
vi.mock("@vrtmrz/obsidian-test-session", () => ({
startObsidianPluginSession: vi.fn(async () => ({
app: {},
cliEnv: {},
install: {},
readiness: {},
pluginId: "obsidian-livesync",
remoteDebuggingPort: 28052,
})),
}));
describe("LiveSync real-Obsidian session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("installs an explicitly selected plug-in artefact while retaining the supplied Vault and profile", async () => {
const vault = {
path: "/tmp/upgrade-vault",
statePath: "/tmp/upgrade-state",
name: "upgrade-vault",
id: "upgrade-vault-id",
homePath: "/tmp/upgrade-state/home",
xdgConfigPath: "/tmp/upgrade-state/xdg-config",
xdgCachePath: "/tmp/upgrade-state/xdg-cache",
xdgDataPath: "/tmp/upgrade-state/xdg-data",
userDataPath: "/tmp/upgrade-state/user-data",
processMarker: "/tmp/upgrade-state",
dispose: vi.fn(async () => undefined),
};
const options: StartObsidianLiveSyncSessionOptions & { artifactRoot: string } = {
binary: "/Applications/Obsidian",
cliBinary: "obsidian-cli",
vault,
artifactRoot: "/tmp/obsidian-livesync-0.25.83",
};
await startObsidianLiveSyncSession(options);
expect(startObsidianPluginSession).toHaveBeenCalledWith(
expect.objectContaining({
artifactRoot: options.artifactRoot,
pluginId: "obsidian-livesync",
vault,
})
);
});
});
+2 -1
View File
@@ -7,6 +7,7 @@ export type StartObsidianLiveSyncSessionOptions = {
binary: string;
cliBinary: string;
vault: TemporaryVault;
artifactRoot?: string;
startupGraceMs?: number;
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
@@ -21,7 +22,7 @@ export async function startObsidianLiveSyncSession(
cliBinary: options.cliBinary,
vault: options.vault,
pluginId: "obsidian-livesync",
artifactRoot: process.cwd(),
artifactRoot: options.artifactRoot ?? process.cwd(),
startupGraceMs: options.startupGraceMs,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
@@ -39,7 +39,6 @@ vi.mock("./liveSyncWorkflow.ts", () => ({
assertE2eCompatibilityReviewPending: vi.fn(async () => undefined),
configureCouchDb: vi.fn(async () => undefined),
createE2eCouchDbPluginData: vi.fn(() => ({})),
createE2eObsidianDeviceLocalState: vi.fn(() => ({})),
prepareRemote: vi.fn(async () => undefined),
pushLocalChanges: vi.fn(async () => {
throw new Error("simulated Obsidian CLI timeout");
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
assertCouchDbCheckpointContinuity,
assertJournalCheckpointLoaded,
assertNoJournalReplay,
type JournalCheckpointSnapshot,
} from "./upgradeContinuity.ts";
const journalCheckpoint: JournalCheckpointSnapshot = {
remoteKey: "remote-a",
lastLocalSeq: 42,
journalEpoch: "2:salt",
knownIDs: ["known-a"],
sentIDs: ["sent-a"],
receivedFiles: ["100-docs.jsonl.gz"],
sentFiles: ["101-docs.jsonl.gz"],
};
describe("upgrade synchronisation continuity assertions", () => {
it("rejects a fresh CouchDB checkpoint lineage even when final documents could still converge", () => {
expect(() =>
assertCouchDbCheckpointContinuity(
[{ id: "_local/original", lastSequence: 42 }],
[{ id: "_local/replacement", lastSequence: 42 }]
)
).toThrow("checkpoint identity changed");
});
it("rejects an Object Storage checkpoint which was reset to its initial state", () => {
expect(() =>
assertJournalCheckpointLoaded(journalCheckpoint, {
remoteKey: journalCheckpoint.remoteKey,
lastLocalSeq: 0,
journalEpoch: "",
knownIDs: [],
sentIDs: [],
receivedFiles: [],
sentFiles: [],
})
).toThrow(/lastLocalSeq regressed|history was lost/u);
});
it("rejects hidden Object Storage replay during an otherwise unchanged sync", () => {
expect(() =>
assertNoJournalReplay(
journalCheckpoint,
journalCheckpoint,
[{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }],
[{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }],
{ downloadedJournalKeys: ["101-docs.jsonl.gz"], uploadedJournalKeys: [] }
)
).toThrow("downloaded previously processed journals");
});
});
@@ -0,0 +1,199 @@
export type CouchDbCheckpointSnapshot = {
id: string;
lastSequence: unknown;
};
export type CouchDbDocumentRevision = {
id: string;
revision: string;
deleted: boolean;
};
export type JournalCheckpointSnapshot = {
remoteKey: string;
lastLocalSeq: number | string;
journalEpoch: string;
knownIDs: readonly string[];
sentIDs: readonly string[];
receivedFiles: readonly string[];
sentFiles: readonly string[];
};
export type JournalIoObservation = {
downloadedJournalKeys: readonly string[];
uploadedJournalKeys: readonly string[];
};
export type RemoteObjectSnapshot = {
key: string;
size: number;
etag: string;
};
export type MilestoneIdentity = {
created: unknown;
locked: boolean;
acceptedNodes: readonly string[];
};
function sorted(values: readonly string[]): string[] {
return [...values].sort((left, right) => left.localeCompare(right));
}
function assertEqualStrings(actual: readonly string[], expected: readonly string[], message: string): void {
const actualSorted = sorted(actual);
const expectedSorted = sorted(expected);
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`);
}
}
function assertSubset(previous: readonly string[], current: readonly string[], message: string): void {
const currentSet = new Set(current);
const missing = previous.filter((value) => !currentSet.has(value));
if (missing.length > 0) throw new Error(`${message}: ${missing.join(", ")}`);
}
function sequenceNumber(sequence: unknown): number | undefined {
if (typeof sequence === "number" && Number.isFinite(sequence)) return sequence;
if (typeof sequence !== "string") return undefined;
const match = /^(\d+)/u.exec(sequence);
return match ? Number(match[1]) : undefined;
}
function assertSequenceDidNotRegress(before: unknown, after: unknown, label: string): void {
const beforeNumber = sequenceNumber(before);
const afterNumber = sequenceNumber(after);
if (beforeNumber !== undefined && afterNumber !== undefined) {
if (afterNumber < beforeNumber) {
throw new Error(`${label} regressed from ${String(before)} to ${String(after)}.`);
}
return;
}
if (before !== after) {
throw new Error(`${label} changed from an opaque sequence ${String(before)} to ${String(after)}.`);
}
}
export function assertCouchDbCheckpointContinuity(
before: readonly CouchDbCheckpointSnapshot[],
after: readonly CouchDbCheckpointSnapshot[]
): void {
if (before.length === 0) throw new Error("The stable release did not create a CouchDB replication checkpoint.");
assertEqualStrings(
after.map(({ id }) => id),
before.map(({ id }) => id),
"The CouchDB replication checkpoint identity changed during the upgrade."
);
const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint]));
for (const checkpoint of before) {
assertSequenceDidNotRegress(
checkpoint.lastSequence,
afterById.get(checkpoint.id)?.lastSequence,
`CouchDB checkpoint ${checkpoint.id}`
);
}
}
export function assertSomeCouchDbCheckpointAdvanced(
before: readonly CouchDbCheckpointSnapshot[],
after: readonly CouchDbCheckpointSnapshot[]
): void {
assertCouchDbCheckpointContinuity(before, after);
const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint]));
const advanced = before.some((checkpoint) => {
const previous = sequenceNumber(checkpoint.lastSequence);
const current = sequenceNumber(afterById.get(checkpoint.id)?.lastSequence);
return previous !== undefined && current !== undefined && current > previous;
});
if (!advanced) throw new Error("No CouchDB replication checkpoint advanced after the post-upgrade change.");
}
export function assertCouchDbDocumentsUnchanged(
before: readonly CouchDbDocumentRevision[],
after: readonly CouchDbDocumentRevision[]
): void {
const serialise = (documents: readonly CouchDbDocumentRevision[]) =>
[...documents].sort((left, right) => left.id.localeCompare(right.id));
if (JSON.stringify(serialise(before)) !== JSON.stringify(serialise(after))) {
throw new Error("A no-op post-upgrade CouchDB synchronisation changed ordinary remote documents.");
}
}
export function assertJournalCheckpointLoaded(
before: JournalCheckpointSnapshot,
after: JournalCheckpointSnapshot
): void {
if (sequenceNumber(before.lastLocalSeq) === 0) {
throw new Error("The stable release did not advance the Object Storage local checkpoint.");
}
if (after.remoteKey !== before.remoteKey) {
throw new Error(`The Object Storage checkpoint key changed from ${before.remoteKey} to ${after.remoteKey}.`);
}
assertSequenceDidNotRegress(before.lastLocalSeq, after.lastLocalSeq, "Object Storage lastLocalSeq");
assertSubset(before.knownIDs, after.knownIDs, "Object Storage known revision history was lost");
assertSubset(before.sentIDs, after.sentIDs, "Object Storage sent revision history was lost");
assertSubset(before.receivedFiles, after.receivedFiles, "Object Storage received journal history was lost");
assertSubset(before.sentFiles, after.sentFiles, "Object Storage sent journal history was lost");
if (before.journalEpoch && after.journalEpoch !== before.journalEpoch) {
throw new Error(
`The Object Storage journal epoch changed from ${before.journalEpoch} to ${after.journalEpoch}.`
);
}
}
export function assertNoJournalReplay(
beforeCheckpoint: JournalCheckpointSnapshot,
afterCheckpoint: JournalCheckpointSnapshot,
beforeObjects: readonly RemoteObjectSnapshot[],
afterObjects: readonly RemoteObjectSnapshot[],
observation: JournalIoObservation
): void {
assertJournalCheckpointLoaded(beforeCheckpoint, afterCheckpoint);
assertEqualStrings(
afterObjects.map(({ key }) => key),
beforeObjects.map(({ key }) => key),
"A no-op post-upgrade Object Storage synchronisation changed the journal object set."
);
if (observation.downloadedJournalKeys.length > 0) {
throw new Error(
`The no-op synchronisation downloaded previously processed journals: ${observation.downloadedJournalKeys.join(", ")}`
);
}
if (observation.uploadedJournalKeys.length > 0) {
throw new Error(
`The no-op synchronisation uploaded replay journals: ${observation.uploadedJournalKeys.join(", ")}`
);
}
}
export function assertJournalCheckpointAdvanced(
before: JournalCheckpointSnapshot,
after: JournalCheckpointSnapshot,
observation: JournalIoObservation
): void {
assertJournalCheckpointLoaded(before, after);
const beforeSequence = sequenceNumber(before.lastLocalSeq);
const afterSequence = sequenceNumber(after.lastLocalSeq);
if (beforeSequence === undefined || afterSequence === undefined || afterSequence <= beforeSequence) {
throw new Error(
`The Object Storage checkpoint did not advance after the post-upgrade change (${String(before.lastLocalSeq)} -> ${String(after.lastLocalSeq)}).`
);
}
if (observation.uploadedJournalKeys.length === 0) {
throw new Error("The post-upgrade Object Storage change did not create a new journal.");
}
}
export function assertMilestoneContinuity(before: MilestoneIdentity, after: MilestoneIdentity): void {
if (before.created === undefined || before.created === null) {
throw new Error("The stable release milestone does not expose a remote generation identity.");
}
if (after.created !== before.created) {
throw new Error(`The remote milestone generation changed from ${String(before.created)} to ${String(after.created)}.`);
}
if (after.locked !== before.locked) {
throw new Error(`The remote milestone lock changed from ${String(before.locked)} to ${String(after.locked)}.`);
}
assertSubset(before.acceptedNodes, after.acceptedNodes, "The remote milestone lost an accepted device");
}
+837
View File
@@ -0,0 +1,837 @@
import { mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { evalObsidianJson } from "./cli.ts";
import type { CouchDbConfig } from "./couchdb.ts";
import type { ObjectStorageConfig } from "./objectStorage.ts";
import { withObsidianPage } from "./ui.ts";
import type {
CouchDbCheckpointSnapshot,
JournalCheckpointSnapshot,
JournalIoObservation,
} from "./upgradeContinuity.ts";
import { waitForLocalDatabaseEntry } from "./liveSyncWorkflow.ts";
import type { TemporaryVault } from "./vault.ts";
export const STABLE_RELEASE_VERSION = "0.25.83";
export type UpgradeTransportConfiguration =
| {
kind: "couchdb";
config: CouchDbConfig;
databaseName: string;
}
| {
kind: "object-storage";
config: ObjectStorageConfig;
bucketPrefix: string;
};
export type UpgradeScenarioPaths = {
original: string;
renamed: string;
deleted: string;
postUpgrade: string;
returnFromVerifier: string;
};
export type RuntimeUpgradeState = {
pluginVersion: string;
vaultName: string;
localDatabaseName: string;
localDatabaseUpdateSequence: number | string;
localDatabaseDocumentCount: number;
nodeId: string;
legacyCompatibilityMarker: string | null;
compatibilityMarker: string;
compatibilityStorageEntries: Record<string, string>;
migrationState?: {
sourceVersion: number;
targetVersion: number;
isNewVault: boolean;
isFromFutureSchema: boolean;
changed: boolean;
requiresSyncReview: boolean;
reviewReasons: Array<{ code: string; fromVersion: number; toVersion: number }>;
};
settings: {
isConfigured: boolean | undefined;
settingVersion: number;
versionUpFlash: string;
liveSync: boolean;
syncOnStart: boolean;
syncOnSave: boolean;
syncOnEditorSave: boolean;
syncOnFileOpen: boolean;
syncAfterMerge: boolean;
periodicReplication: boolean;
encrypt: boolean;
usePathObfuscation: boolean;
syncInternalFiles: boolean;
customChunkSize: number;
usePluginSyncV2: boolean;
enableCompression: boolean;
useEden: boolean;
filenameCaseType: string;
handleFilenameCaseSensitive?: boolean;
doNotUseFixedRevisionForChunks: boolean;
chunkSplitterVersion: string;
E2EEAlgorithm: string;
additionalSuffixOfDatabaseName: string;
remoteType: string;
couchDB_DBNAME: string;
endpoint: string;
bucket: string;
bucketPrefix: string;
activeConfigurationId: string;
remoteConfigurationIds: string[];
doctorProcessedVersion: string;
};
};
export type RuntimeSettingsUpgradeState = Pick<RuntimeUpgradeState, "pluginVersion" | "migrationState" | "settings"> & {
compatibilityMarker: string;
};
export type CouchDbReplicationObservation = {
succeeded: boolean;
sentDocuments: number;
arrivedDocuments: number;
};
export type JournalReplicationObservation = JournalIoObservation & {
succeeded: boolean;
};
const firstContent = "# Stable release history\n\nCreated before the 1.0 upgrade.\n";
const editedContent = "# Stable release history\n\nEdited and renamed before the 1.0 upgrade.\n";
const deletedContent = "# Deleted before upgrade\n\nThis note must not be resurrected.\n";
const postUpgradeContent = "# Post-upgrade delta\n\nCreated by the upgraded 1.0 device.\n";
const returnContent = "# Return journey\n\nCreated by a fresh 1.0 verifier device.\n";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function assertStringArraysEqual(actual: readonly string[], expected: readonly string[], message: string): void {
const actualSorted = [...actual].sort();
const expectedSorted = [...expected].sort();
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(
`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`
);
}
}
export function createUpgradeScenarioPaths(label: string): UpgradeScenarioPaths {
const root = `E2E/upgrade-from-${STABLE_RELEASE_VERSION}/${label}`;
return {
original: `${root}/rename-source.md`,
renamed: `${root}/renamed.md`,
deleted: `${root}/deleted.md`,
postUpgrade: `${root}/post-upgrade.md`,
returnFromVerifier: `${root}/return-from-verifier.md`,
};
}
function remoteSettings(configuration: UpgradeTransportConfiguration): Record<string, unknown> {
if (configuration.kind === "couchdb") {
return {
remoteType: "",
couchDB_URI: configuration.config.uri,
couchDB_USER: configuration.config.username,
couchDB_PASSWORD: configuration.config.password,
couchDB_DBNAME: configuration.databaseName,
isConfigured: true,
};
}
return {
remoteType: "MINIO",
endpoint: configuration.config.endpoint,
accessKey: configuration.config.accessKey,
secretKey: configuration.config.secretKey,
bucket: configuration.config.bucket,
region: configuration.config.region,
forcePathStyle: configuration.config.forcePathStyle,
bucketPrefix: configuration.bucketPrefix,
bucketCustomHeaders: "",
isConfigured: true,
};
}
export async function configureStableRelease(
cliBinary: string,
environment: NodeJS.ProcessEnv,
configuration: UpgradeTransportConfiguration
): Promise<void> {
const partial = remoteSettings(configuration);
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const partial=${JSON.stringify(partial)};`,
"await core.services.setting.applyExternalSettings(partial,true);",
"await core.services.control.applySettings();",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
export async function prepareStableRemote(cliBinary: string, environment: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const settings=core.services.setting.currentSettings();",
"const replicator=core.services.replicator.getActiveReplicator();",
"await replicator.tryCreateRemoteDatabase(settings);",
"await replicator.markRemoteResolved(settings);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
export async function waitForPersistentNodeIdentity(
cliBinary: string,
environment: NodeJS.ProcessEnv,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
): Promise<string> {
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const database=core.localDatabase.localDatabase;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"let persistent='';let active='';",
"while(Date.now()<deadline){",
"const nodeInfo=await database.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);",
"persistent=typeof nodeInfo?.nodeid==='string'?nodeInfo.nodeid:'';",
"active=core.services.replicator.getActiveReplicator()?.nodeid??'';",
"if(persistent!==''&&active===persistent) return JSON.stringify(persistent);",
"await sleep(100);",
"}",
"throw new Error(`Timed out waiting for persistent node identity: persistent=${persistent}, active=${active}`);",
"})()",
].join(""),
environment
);
}
export async function readRuntimeUpgradeState(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<RuntimeUpgradeState> {
return await evalObsidianJson<RuntimeUpgradeState>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const core=plugin.core;",
"const setting=core.services.setting;",
"const settings=setting.currentSettings();",
"const vaultName=core.services.vault.getVaultName();",
"const replicator=core.services.replicator.getActiveReplicator();",
"const databaseInfo=await core.localDatabase.localDatabase.info();",
"const nodeInfo=await core.localDatabase.localDatabase.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);",
"const migrationState=setting.getSettingsMigrationState?.();",
"return JSON.stringify({",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
"vaultName,",
"localDatabaseName:databaseInfo.db_name,",
"localDatabaseUpdateSequence:databaseInfo.update_seq,",
"localDatabaseDocumentCount:databaseInfo.doc_count,",
"nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',",
"legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),",
"compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',",
"compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))",
".filter((key)=>key!==null)",
".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))",
".map(key=>[key,localStorage.getItem(key)??''])),",
"migrationState,",
"settings:{",
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
"versionUpFlash:settings.versionUpFlash,",
"liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,",
"syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,",
"syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,",
"encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,",
"syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,",
"usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,",
"useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,",
"handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,",
"doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,",
"chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,",
"additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',",
"remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',",
"endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',",
"activeConfigurationId:settings.activeConfigurationId??'',",
"remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),",
"doctorProcessedVersion:settings.doctorProcessedVersion??'',",
"}",
"});",
"})()",
].join(""),
environment
);
}
export async function readRuntimeSettingsUpgradeState(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<RuntimeSettingsUpgradeState> {
return await evalObsidianJson<RuntimeSettingsUpgradeState>(
cliBinary,
[
"(async()=>{",
"const plugin=app.plugins.plugins['obsidian-livesync'];",
"const setting=plugin.core.services.setting;",
"const settings=setting.currentSettings();",
"const migrationState=setting.getSettingsMigrationState?.();",
"return JSON.stringify({",
"pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',",
"compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',",
"migrationState,",
"settings:{",
"isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,",
"versionUpFlash:settings.versionUpFlash,",
"liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,",
"syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,",
"syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,",
"encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,",
"syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,",
"usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,",
"useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,",
"handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,",
"doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,",
"chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,",
"additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',",
"remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',",
"endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',",
"activeConfigurationId:settings.activeConfigurationId??'',",
"remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),",
"doctorProcessedVersion:settings.doctorProcessedVersion??'',",
"}",
"});",
"})()",
].join(""),
environment
);
}
export function assertStableReleaseDefaults(state: RuntimeSettingsUpgradeState, configured: boolean): void {
assertEqual(
state.pluginVersion,
STABLE_RELEASE_VERSION,
"The source session did not load the pinned stable release."
);
assertEqual(state.settings.isConfigured, configured, "The stable release configuration lifecycle was unexpected.");
assertEqual(state.settings.settingVersion, 10, "The stable release settings schema was not version 10.");
assertEqual(state.settings.liveSync, false, "The stable release LiveSync default changed.");
assertEqual(state.settings.syncOnStart, false, "The stable release sync-on-start default changed.");
assertEqual(state.settings.syncOnSave, false, "The stable release sync-on-save default changed.");
assertEqual(state.settings.syncOnEditorSave, false, "The stable release editor-save default changed.");
assertEqual(state.settings.syncOnFileOpen, false, "The stable release file-open default changed.");
assertEqual(state.settings.syncAfterMerge, false, "The stable release post-merge default changed.");
assertEqual(state.settings.periodicReplication, false, "The stable release periodic default changed.");
assertEqual(state.settings.encrypt, false, "The stable release encryption default changed.");
assertEqual(state.settings.usePathObfuscation, false, "The stable release path-obfuscation default changed.");
assertEqual(state.settings.syncInternalFiles, false, "The stable release Hidden File default changed.");
assertEqual(state.settings.customChunkSize, 0, "The stable release custom chunk default changed.");
assertEqual(state.settings.usePluginSyncV2, false, "The stable release Customisation Sync V2 default changed.");
assertEqual(state.settings.enableCompression, false, "The stable release compression default changed.");
assertEqual(state.settings.useEden, false, "The stable release Eden default changed.");
assertEqual(
state.settings.filenameCaseType,
"undefined",
"The stable release filename-case decision was preselected."
);
assertEqual(
state.settings.doNotUseFixedRevisionForChunks,
true,
"The stable release fixed-revision compatibility value changed."
);
assertEqual(state.settings.chunkSplitterVersion, "v3-rabin-karp", "The stable release chunk splitter changed.");
assertEqual(state.settings.E2EEAlgorithm, "v2", "The stable release E2EE algorithm changed.");
assertEqual(
state.settings.remoteConfigurationIds.length,
configured ? 1 : 0,
"The stable release remote-profile count was unexpected."
);
}
export function assertUnconfiguredUpgradeReady(
stable: RuntimeSettingsUpgradeState,
upgraded: RuntimeSettingsUpgradeState,
targetVersion: string
): void {
assertStableReleaseDefaults(stable, false);
assertEqual(upgraded.pluginVersion, targetVersion, "The unconfigured Vault did not load the target artefact.");
assertEqual(upgraded.settings.isConfigured, false, "The upgrade changed an unconfigured Vault to configured.");
assertEqual(
upgraded.settings.usePluginSyncV2,
stable.settings.usePluginSyncV2,
"The upgrade applied a new-Vault recommendation to a non-empty legacy store."
);
assertEqual(
upgraded.settings.handleFilenameCaseSensitive,
false,
"The unconfigured legacy Vault did not retain case-insensitive handling."
);
assertEqual(upgraded.settings.versionUpFlash, "", "The unconfigured Vault was paused for compatibility review.");
assertEqual(
upgraded.compatibilityMarker,
"",
"The unconfigured Vault acknowledged database compatibility before activation."
);
if (!upgraded.migrationState) throw new Error("The unconfigured settings migration state was not available.");
assertEqual(upgraded.migrationState.isNewVault, false, "The non-empty legacy store was treated as a new store.");
// The real-session helper deliberately reloads an already enabled plug-in.
// The first target load performs and persists the migration; the observed
// post-reload state can therefore report changed=false. The workflow reads
// data.json after stopping the session to prove the persisted values.
assertEqual(
upgraded.migrationState.requiresSyncReview,
false,
"The unconfigured legacy settings unexpectedly required compatibility review."
);
assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The unconfigured migration emitted a review reason.");
}
export function assertUnconfiguredUpgradeRestarted(state: RuntimeSettingsUpgradeState, targetVersion: string): void {
assertEqual(state.pluginVersion, targetVersion, "The unconfigured restart did not load the target artefact.");
assertEqual(state.settings.isConfigured, false, "The unconfigured state was not persisted across restart.");
assertEqual(state.settings.usePluginSyncV2, false, "Restart applied a new-Vault recommendation.");
assertEqual(state.settings.handleFilenameCaseSensitive, false, "Restart lost the case-insensitive policy.");
assertEqual(
state.compatibilityMarker,
"",
"Restart acknowledged database compatibility while the Vault remained unconfigured."
);
if (!state.migrationState) throw new Error("The restarted settings migration state was not available.");
assertEqual(state.migrationState.changed, false, "The settings migration was not idempotent after restart.");
assertEqual(state.migrationState.requiresSyncReview, false, "Restart introduced a compatibility review.");
}
export function assertStableRemoteSelection(
state: RuntimeUpgradeState,
configuration: UpgradeTransportConfiguration
): void {
assertEqual(state.settings.isConfigured, true, "The stable release was not marked as configured.");
if (!state.settings.activeConfigurationId) throw new Error("The stable release did not select its remote profile.");
if (!state.settings.remoteConfigurationIds.includes(state.settings.activeConfigurationId)) {
throw new Error("The stable release active remote profile was not persisted.");
}
if (configuration.kind === "couchdb") {
assertEqual(state.settings.remoteType, "", "The stable release did not select CouchDB.");
assertEqual(
state.settings.couchDB_DBNAME,
configuration.databaseName,
"The stable release CouchDB database changed."
);
} else {
assertEqual(state.settings.remoteType, "MINIO", "The stable release did not select Object Storage.");
assertEqual(state.settings.endpoint, configuration.config.endpoint, "The Object Storage endpoint changed.");
assertEqual(state.settings.bucket, configuration.config.bucket, "The Object Storage bucket changed.");
assertEqual(state.settings.bucketPrefix, configuration.bucketPrefix, "The Object Storage prefix changed.");
}
}
export function assertUpgradeCompatibilityReady(
stable: RuntimeUpgradeState,
upgraded: RuntimeUpgradeState,
targetVersion: string,
configuration: UpgradeTransportConfiguration
): void {
assertEqual(upgraded.pluginVersion, targetVersion, "The upgraded session did not load the target artefact.");
assertEqual(
upgraded.localDatabaseName,
stable.localDatabaseName,
"The upgrade opened a different local synchronisation database."
);
if (upgraded.localDatabaseDocumentCount < stable.localDatabaseDocumentCount) {
throw new Error("The upgrade lost local synchronisation documents before its first sync.");
}
if (stable.nodeId.length === 0) {
throw new Error("The stable release did not persist a device node identity.");
}
assertEqual(upgraded.nodeId, stable.nodeId, "The upgrade changed the persistent device node identity.");
assertEqual(
upgraded.settings.additionalSuffixOfDatabaseName,
stable.settings.additionalSuffixOfDatabaseName,
"The upgrade changed the local database suffix."
);
assertStringArraysEqual(
upgraded.settings.remoteConfigurationIds,
stable.settings.remoteConfigurationIds,
"The upgrade changed the stored remote-profile identities."
);
assertEqual(
upgraded.settings.activeConfigurationId,
stable.settings.activeConfigurationId,
"The upgrade changed the active remote profile."
);
assertStableRemoteSelection(upgraded, configuration);
for (const key of [
"liveSync",
"syncOnStart",
"syncOnSave",
"syncOnEditorSave",
"syncOnFileOpen",
"syncAfterMerge",
"periodicReplication",
"customChunkSize",
"usePluginSyncV2",
"enableCompression",
"useEden",
"doNotUseFixedRevisionForChunks",
] as const) {
assertEqual(upgraded.settings[key], stable.settings[key], `The upgrade rewrote the stored ${key} preference.`);
}
if (!upgraded.migrationState) throw new Error("The 1.0 settings migration state was not available.");
// The session helper reloads the enabled target after its first load has
// persisted the normalised case value. Runtime settings below and the
// later restart prove that persisted result without depending on whether
// this observation came from the first or second load.
assertEqual(
upgraded.migrationState.requiresSyncReview,
false,
"The legacy case-insensitive setting unexpectedly required compatibility review."
);
assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The settings migration emitted a spurious review.");
assertEqual(stable.legacyCompatibilityMarker, "12", "The stable release did not persist its legacy marker.");
assertEqual(upgraded.legacyCompatibilityMarker, null, "The upgrade did not retire the legacy marker.");
assertEqual(
upgraded.compatibilityMarker,
"12",
[
"The upgrade did not migrate the legacy compatibility marker.",
`Vault: ${upgraded.vaultName}`,
`Database suffix: ${upgraded.settings.additionalSuffixOfDatabaseName}`,
`Device-local entries: ${JSON.stringify(upgraded.compatibilityStorageEntries)}`,
].join("\n")
);
assertEqual(upgraded.settings.versionUpFlash, "", "Synchronisation was unexpectedly paused after migration.");
assertEqual(
upgraded.settings.handleFilenameCaseSensitive,
false,
"The missing legacy filename-case value did not preserve case-insensitive handling."
);
}
export function assertUpgradeRemainsReady(state: RuntimeUpgradeState, targetVersion: string): void {
assertEqual(state.pluginVersion, targetVersion, "The upgraded session changed target artefact.");
assertEqual(state.settings.versionUpFlash, "", "A compatibility pause reappeared.");
assertEqual(state.compatibilityMarker, "12", "The compatibility acknowledgement was not persisted.");
assertEqual(
state.settings.handleFilenameCaseSensitive,
false,
"The migrated legacy case-insensitive policy was not persisted."
);
}
export async function dismissConfigDoctorIfShown(port: number): Promise<boolean> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
return await withObsidianPage(port, async (page) => {
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
const visible = await doctor
.waitFor({ state: "visible", timeout: Math.min(timeoutMs, 5000) })
.then(() => true)
.catch(() => false);
if (!visible) return false;
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
await doctor.waitFor({ state: "hidden", timeout: timeoutMs });
return true;
});
}
async function writeNote(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
content: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const content=${JSON.stringify(content)};`,
"const folder=path.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.modify(existing,content); else await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function renameNote(
cliBinary: string,
environment: NodeJS.ProcessEnv,
fromPath: string,
toPath: string
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const fromPath=${JSON.stringify(fromPath)};`,
`const toPath=${JSON.stringify(toPath)};`,
"const folder=toPath.split('/').slice(0,-1).join('/');",
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
"const existing=app.vault.getAbstractFileByPath(fromPath);",
"if(!existing) throw new Error(`Could not find note to rename: ${fromPath}`);",
"await app.vault.rename(existing,toPath);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function deleteNote(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const existing=app.vault.getAbstractFileByPath(path);",
"if(!existing) throw new Error(`Could not find note to delete: ${path}`);",
"await app.vault.delete(existing);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
environment
);
}
async function waitForChangedRevision(
cliBinary: string,
environment: NodeJS.ProcessEnv,
path: string,
previousRevision: string
): Promise<void> {
const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000);
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
`const previousRevision=${JSON.stringify(previousRevision)};`,
`const deadline=Date.now()+${JSON.stringify(timeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);",
"if(entry&&entry._rev&&entry._rev!==previousRevision) return JSON.stringify({rev:entry._rev});",
"await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a changed local revision: ${path}`);",
"})()",
].join(""),
environment
);
}
export async function runStableFileHistory(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths,
synchronise: () => Promise<void>
): Promise<void> {
await writeNote(cliBinary, environment, paths.original, firstContent);
await writeNote(cliBinary, environment, paths.deleted, deletedContent);
const originalEntry = await waitForLocalDatabaseEntry(cliBinary, environment, paths.original);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.deleted);
await synchronise();
await writeNote(cliBinary, environment, paths.original, editedContent);
await waitForChangedRevision(cliBinary, environment, paths.original, originalEntry.rev);
await synchronise();
await renameNote(cliBinary, environment, paths.original, paths.renamed);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.renamed);
await synchronise();
await deleteNote(cliBinary, environment, paths.deleted);
await synchronise();
}
export async function createPostUpgradeDelta(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths
): Promise<void> {
await writeNote(cliBinary, environment, paths.postUpgrade, postUpgradeContent);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.postUpgrade);
}
export async function createVerifierReturnDelta(
cliBinary: string,
environment: NodeJS.ProcessEnv,
paths: UpgradeScenarioPaths
): Promise<void> {
await writeNote(cliBinary, environment, paths.returnFromVerifier, returnContent);
await waitForLocalDatabaseEntry(cliBinary, environment, paths.returnFromVerifier);
}
async function pathExists(vault: TemporaryVault, path: string): Promise<boolean> {
try {
await readFile(join(vault.path, path));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
async function waitForPathContent(vault: TemporaryVault, path: string, content: string): Promise<void> {
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
let lastContent = "";
while (Date.now() < deadline) {
try {
lastContent = await readFile(join(vault.path, path), "utf8");
if (lastContent === content) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
export async function verifyPreUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await waitForPathContent(vault, paths.renamed, editedContent);
if (await pathExists(vault, paths.original)) throw new Error(`Renamed source was resurrected: ${paths.original}`);
if (await pathExists(vault, paths.deleted)) throw new Error(`Deleted note was resurrected: ${paths.deleted}`);
}
export async function verifyPostUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await verifyPreUpgradeHistory(vault, paths);
await waitForPathContent(vault, paths.postUpgrade, postUpgradeContent);
}
export async function verifyReturnDelta(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await waitForPathContent(vault, paths.returnFromVerifier, returnContent);
}
export async function ensureScenarioDirectory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise<void> {
await mkdir(dirname(join(vault.path, paths.original)), { recursive: true });
}
export async function runCouchDbReplicationObserved(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<CouchDbReplicationObservation> {
return await evalObsidianJson<CouchDbReplicationObservation>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"await core.services.fileProcessing.commitPendingFileEvents();",
"const beforeSent=Number(replicator.docSent??0);",
"const beforeArrived=Number(replicator.docArrived??0);",
"const result=await core.services.replication.replicate(true);",
"return JSON.stringify({",
"succeeded:!!result,",
"sentDocuments:Number(replicator.docSent??0)-beforeSent,",
"arrivedDocuments:Number(replicator.docArrived??0)-beforeArrived,",
"});",
"})()",
].join(""),
environment
);
}
export async function runJournalReplicationObserved(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<JournalReplicationObservation> {
return await evalObsidianJson<JournalReplicationObservation>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const client=replicator.client;",
"const storage=client.storage;",
"const originalDownload=storage.download.bind(storage);",
"const originalUpload=storage.upload.bind(storage);",
"const downloadedJournalKeys=[];const uploadedJournalKeys=[];",
"const isJournal=(key)=>!String(key).split('/').pop().startsWith('_');",
"storage.download=async(key,...args)=>{if(isJournal(key))downloadedJournalKeys.push(String(key));return await originalDownload(key,...args);};",
"storage.upload=async(key,...args)=>{if(isJournal(key))uploadedJournalKeys.push(String(key));return await originalUpload(key,...args);};",
"let succeeded=false;",
"try{",
"await core.services.fileProcessing.commitPendingFileEvents();",
"succeeded=!!(await core.services.replication.replicate(true));",
"}finally{storage.download=originalDownload;storage.upload=originalUpload;}",
"return JSON.stringify({succeeded,downloadedJournalKeys,uploadedJournalKeys});",
"})()",
].join(""),
environment
);
}
export async function readJournalCheckpoint(
cliBinary: string,
environment: NodeJS.ProcessEnv
): Promise<JournalCheckpointSnapshot> {
return await evalObsidianJson<JournalCheckpointSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const replicator=core.services.replicator.getActiveReplicator();",
"const client=replicator.client;",
"const checkpoint=await client.getCheckpointInfo();",
"const sorted=(value)=>[...(value??[])].sort();",
"return JSON.stringify({",
"remoteKey:client.getRemoteKey(),lastLocalSeq:checkpoint.lastLocalSeq,journalEpoch:checkpoint.journalEpoch,",
"knownIDs:sorted(checkpoint.knownIDs),sentIDs:sorted(checkpoint.sentIDs),",
"receivedFiles:sorted(checkpoint.receivedFiles),sentFiles:sorted(checkpoint.sentFiles),",
"});",
"})()",
].join(""),
environment
);
}
export async function readLocalCouchDbCheckpoints(
cliBinary: string,
environment: NodeJS.ProcessEnv,
checkpointIds: readonly string[]
): Promise<CouchDbCheckpointSnapshot[]> {
return await evalObsidianJson<CouchDbCheckpointSnapshot[]>(
cliBinary,
[
"(async()=>{",
`const ids=${JSON.stringify(checkpointIds)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const database=core.localDatabase.localDatabase;",
"const checkpoints=[];",
"for(const id of ids){",
"const doc=await database.get(id).catch(()=>false);",
"if(doc&&Object.prototype.hasOwnProperty.call(doc,'last_seq')) checkpoints.push({id,lastSequence:doc.last_seq});",
"}",
"return JSON.stringify(checkpoints);",
"})()",
].join(""),
environment
);
}
+1
View File
@@ -23,6 +23,7 @@ const focusedScenarios = new Set([
"hidden-file-snippet-sync",
"customisation-sync",
"setting-markdown-export",
"upgrade-from-stable",
]);
function usage(): string {
@@ -0,0 +1,753 @@
import { spawn } from "node:child_process";
import { access, readFile, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchAllCouchDbDocs,
fetchCouchDbDatabaseInfo,
fetchCouchDbLocalDocs,
loadCouchDbConfig,
makeUniqueDatabaseName,
type CouchDbConfig,
type CouchDbDatabaseInfo,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
configureCouchDb,
configureObjectStorage,
createE2eCouchDbPluginData,
createE2eObjectStoragePluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
listObjectStorageObjects,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
type ObjectStorageConfig,
} from "../runner/objectStorage.ts";
import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
assertCouchDbCheckpointContinuity,
assertCouchDbDocumentsUnchanged,
assertJournalCheckpointAdvanced,
assertJournalCheckpointLoaded,
assertMilestoneContinuity,
assertNoJournalReplay,
assertSomeCouchDbCheckpointAdvanced,
type CouchDbCheckpointSnapshot,
type CouchDbDocumentRevision,
type MilestoneIdentity,
type RemoteObjectSnapshot,
} from "../runner/upgradeContinuity.ts";
import {
assertStableReleaseDefaults,
assertStableRemoteSelection,
assertUnconfiguredUpgradeReady,
assertUnconfiguredUpgradeRestarted,
assertUpgradeCompatibilityReady,
assertUpgradeRemainsReady,
configureStableRelease,
createPostUpgradeDelta,
createUpgradeScenarioPaths,
createVerifierReturnDelta,
dismissConfigDoctorIfShown,
prepareStableRemote,
readJournalCheckpoint,
readLocalCouchDbCheckpoints,
readRuntimeUpgradeState,
readRuntimeSettingsUpgradeState,
runCouchDbReplicationObserved,
runJournalReplicationObserved,
runStableFileHistory,
STABLE_RELEASE_VERSION,
verifyPostUpgradeHistory,
verifyPreUpgradeHistory,
verifyReturnDelta,
waitForPersistentNodeIdentity,
type CouchDbReplicationObservation,
type RuntimeUpgradeState,
type UpgradeTransportConfiguration,
} from "../runner/upgradeWorkflow.ts";
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
type Transport = "couchdb" | "object-storage";
type RemoteMilestone = CouchDbDocument & {
created?: unknown;
locked?: unknown;
accepted_nodes?: unknown;
tweak_values?: unknown;
};
type CouchDbRemoteSnapshot = {
checkpoints: CouchDbCheckpointSnapshot[];
documents: CouchDbDocumentRevision[];
info: CouchDbDatabaseInfo;
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type ObjectStorageRemoteSnapshot = {
journalObjects: RemoteObjectSnapshot[];
milestone: MilestoneIdentity;
preferredTweaks: Record<string, unknown>;
};
type RunnerContext = {
binary: string;
cliBinary: string;
sourceArtifactRoot: string;
targetArtifactRoot: string;
targetVersion: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
type ParsedArguments = {
transports: Transport[];
manageServices: boolean;
keepServices: boolean;
};
type StartSessionOptions = {
pluginData?: Record<string, unknown>;
localStorageEntries?: Readonly<Record<string, string>>;
waitForCoreReady?: boolean;
};
const MILESTONE_ID = "_local/obsydian_livesync_milestone";
const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function parseArguments(argv: readonly string[]): ParsedArguments {
let transportValue = "all";
for (let index = 0; index < argv.length; index++) {
const argument = argv[index];
if (argument === "--transport") {
transportValue = argv[index + 1] ?? "";
index++;
} else if (argument.startsWith("--transport=")) {
transportValue = argument.slice("--transport=".length);
}
}
const transports: Transport[] =
transportValue === "all"
? ["couchdb", "object-storage"]
: transportValue === "couchdb" || transportValue === "object-storage"
? [transportValue]
: (() => {
throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`);
})();
return {
transports,
manageServices: argv.includes("--manage-services"),
keepServices: argv.includes("--keep-services"),
};
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort(process.env);
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(name: string, optional = false): Promise<void> {
return new Promise((resolvePromise, reject) => {
console.log(`\n# ${name}`);
const child = spawn(npmBinary(), ["run", name], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0 || optional) {
if (code !== 0) {
console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
}
resolvePromise();
return;
}
reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`));
});
});
}
async function validateTargetArtifact(root: string): Promise<string> {
await Promise.all(
["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name)))
);
const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as {
id?: unknown;
version?: unknown;
};
assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id.");
assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version.");
assert(
manifest.version !== STABLE_RELEASE_VERSION,
`The target artefact is still the source release ${STABLE_RELEASE_VERSION}.`
);
return manifest.version;
}
async function startSession(
context: RunnerContext,
vault: TemporaryVault,
port: number,
artifactRoot: string,
options: StartSessionOptions = {}
): Promise<ObsidianLiveSyncSession> {
const session = await startObsidianLiveSyncSession({
binary: context.binary,
cliBinary: context.cliBinary,
vault,
artifactRoot,
pluginData: options.pluginData,
localStorageEntries: options.localStorageEntries,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(port),
});
context.activeSessions.add(session);
try {
if (options.waitForCoreReady !== false) {
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
}
return session;
} catch (error) {
await stopSession(context, session).catch(() => undefined);
throw error;
}
}
async function readStoredPluginData(vault: TemporaryVault): Promise<Record<string, unknown>> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
return JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
}
async function writeStoredPluginData(vault: TemporaryVault, data: Record<string, unknown>): Promise<void> {
const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json");
await writeFile(path, `${JSON.stringify(data, null, 2)}\n`);
}
async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`);
const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-");
try {
let session = await startSession(context, vault, port, context.sourceArtifactRoot, {
pluginData: { liveSync: false },
waitForCoreReady: false,
});
const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(stableState, false);
await stopSession(context, session);
const stableData = await readStoredPluginData(vault);
if (stableData.isConfigured !== undefined) {
assertEqual(
stableData.isConfigured,
false,
"The stable release persisted a configured state for its default-equivalent settings."
);
}
// 0.25.83 infers the runtime boolean, but persistence depends on an
// unrelated settings-save event. Restore the pre-flag document
// explicitly so the target proves the direct legacy migration in
// either case rather than depending on that timing.
await writeStoredPluginData(vault, { liveSync: false });
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion);
await stopSession(context, session);
const migratedData = await readStoredPluginData(vault);
assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved.");
assertEqual(
migratedData.handleFilenameCaseSensitive,
false,
"The inferred case-insensitive setting was not saved."
);
session = await startSession(context, vault, port, context.targetArtifactRoot, {
waitForCoreReady: false,
});
const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv);
assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion);
await stopSession(context, session);
console.log(
`PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.`
);
} finally {
await stopSessions(context);
await vault.dispose();
}
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) await stopSession(context, session);
}
function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity {
assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation.");
assert(typeof document.locked === "boolean", "The remote milestone has no lock state.");
assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list.");
assert(
document.accepted_nodes.every((value) => typeof value === "string"),
"The remote milestone accepted-device list is malformed."
);
return {
created: document.created,
locked: document.locked,
acceptedNodes: document.accepted_nodes,
};
}
function preferredTweaks(document: RemoteMilestone): Record<string, unknown> {
const values = document.tweak_values;
assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map.");
const preferred = (values as Record<string, unknown>).PREFERRED;
assert(
preferred !== null && typeof preferred === "object" && !Array.isArray(preferred),
"The remote has no preferred tweak settings."
);
return { ...(preferred as Record<string, unknown>) };
}
async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise<CouchDbRemoteSnapshot> {
const [allDocs, localDocs, info] = await Promise.all([
fetchAllCouchDbDocs(config, databaseName),
fetchCouchDbLocalDocs(config, databaseName),
fetchCouchDbDatabaseInfo(config, databaseName),
]);
const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined;
assert(milestone, "The CouchDB remote milestone is missing.");
const checkpoints = localDocs.rows.flatMap(({ id, doc }) =>
doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : []
);
const documents = allDocs.rows.map(({ id, value }) => ({
id,
revision: value.rev,
deleted: value.deleted === true,
}));
return {
checkpoints,
documents,
info,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
async function readObjectStorageRemoteSnapshot(
config: ObjectStorageConfig,
prefix: string
): Promise<ObjectStorageRemoteSnapshot> {
const [objects, milestone] = await Promise.all([
listObjectStorageObjects(config, prefix),
readObjectStorageJson<RemoteMilestone>(config, `${prefix}${JOURNAL_MILESTONE_NAME}`),
]);
const journalObjects = objects.flatMap((object) => {
if (!object.Key || basename(object.Key).startsWith("_")) return [];
return [
{
key: object.Key,
size: object.Size ?? 0,
etag: object.ETag ?? "",
},
];
});
return {
journalObjects,
milestone: milestoneIdentity(milestone),
preferredTweaks: preferredTweaks(milestone),
};
}
function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void {
assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed.");
assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents.");
assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents.");
}
function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void {
assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints);
assertCouchDbDocumentsUnchanged(before.documents, after.documents);
assertEqual(
after.info.update_seq,
before.info.update_seq,
"The no-op CouchDB synchronisation advanced update_seq."
);
assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count.");
assertMilestoneContinuity(before.milestone, after.milestone);
}
function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void {
assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database.");
assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity.");
assertEqual(
after.settings.activeConfigurationId,
before.settings.activeConfigurationId,
"Restart changed the active remote profile."
);
}
async function configureFreshCouchDbVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: CouchDbConfig,
databaseName: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureCouchDb(
context.cliBinary,
session.cliEnv,
{ uri: config.uri, username: config.username, password: config.password, dbName: databaseName },
tweaks
);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function configureFreshObjectStorageVerifier(
context: RunnerContext,
session: ObsidianLiveSyncSession,
config: ObjectStorageConfig,
prefix: string,
tweaks: Record<string, unknown>
): Promise<void> {
await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await prepareRemote(context.cliBinary, session.cliEnv);
}
async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`);
const config = await loadCouchDbConfig();
const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName };
const paths = createUpgradeScenarioPaths("couchdb");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await assertCouchDbReachable(config);
await createCouchDbDatabase(config, databaseName);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(result.succeeded, "The stable CouchDB synchronisation failed.");
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName);
const stableLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints(
context.cliBinary,
session.cliEnv,
stableRemote.checkpoints.map(({ id }) => id)
);
assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints);
const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assertNoOpCouchDbObservation(noOpObservation);
const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertNoOpCouchDbDatabase(stableRemote, noOpRemote);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed.");
assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents.");
const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName);
assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = {
uri: config.uri,
username: config.username,
password: config.password,
dbName: databaseName,
};
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta.");
assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise<void> {
console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`);
const config = await loadObjectStorageConfig();
const prefix = makeUniqueBucketPrefix("upgrade-from-stable");
const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix };
const paths = createUpgradeScenarioPaths("object-storage");
const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-");
const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-");
let upgradedSession: ObsidianLiveSyncSession | undefined;
try {
await ensureObjectStorageBucket(config);
let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false);
await configureStableRelease(context.cliBinary, session.cliEnv, remote);
const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(configuredStable, true);
assertStableRemoteSelection(configuredStable, remote);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot);
const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertStableReleaseDefaults(restartedStable, true);
assertStableRemoteSelection(restartedStable, remote);
await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv);
await prepareStableRemote(context.cliBinary, session.cliEnv);
await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => {
const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(result.succeeded, "The stable Object Storage synchronisation failed.");
});
await verifyPreUpgradeHistory(upgradeVault, paths);
const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix);
await stopSession(context, session);
session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
upgradedSession = session;
await dismissConfigDoctorIfShown(session.remoteDebuggingPort);
const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv);
assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote);
await verifyPreUpgradeHistory(upgradeVault, paths);
const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint);
const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed.");
const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertNoJournalReplay(
stableCheckpoint,
noOpCheckpoint,
stableRemote.journalObjects,
noOpRemote.journalObjects,
noOpObservation
);
assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone);
await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths);
const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed.");
const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv);
assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation);
const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix);
assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone);
const verifierSettings = { ...config, bucketPrefix: prefix };
const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, {
pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks),
localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name),
});
await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
await verifyPostUpgradeHistory(verifierVault, paths);
await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths);
await pushLocalChanges(context.cliBinary, verifier.cliEnv);
const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv);
assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta.");
assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive.");
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, verifier);
await stopSession(context, session);
upgradedSession = undefined;
const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot);
const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv);
assertUpgradeRemainsReady(restartedState, context.targetVersion);
assertRestartContinuity(upgradedState, restartedState);
await verifyReturnDelta(upgradeVault, paths);
await stopSession(context, restarted);
console.log(
`PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.`
);
} finally {
if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined);
await stopSessions(context);
await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]);
if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") {
await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
}
async function startManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("couchdb")) {
await runNpmScript("test:docker-couchdb:stop", true);
await runNpmScript("test:docker-couchdb:start");
}
if (transports.includes("object-storage")) {
await runNpmScript("test:docker-s3:stop", true);
await runNpmScript("test:docker-s3:start");
}
}
async function stopManagedServices(transports: readonly Transport[]): Promise<void> {
if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true);
if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true);
}
async function main(): Promise<void> {
const arguments_ = parseArguments(process.argv.slice(2));
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd());
const targetVersion = await validateTargetArtifact(targetArtifactRoot);
const sourceArtifactRoot = await ensurePinnedReleaseArtifact();
const context: RunnerContext = {
binary,
cliBinary: cli.binary,
sourceArtifactRoot,
targetArtifactRoot,
targetVersion,
activeSessions: new Set(),
};
const ports = sessionPorts();
let managedServicesStarted = false;
console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`);
console.log(`Using target release candidate: ${targetVersion}`);
console.log(`Source artefact cache: ${sourceArtifactRoot}`);
console.log(`Target artefact root: ${targetArtifactRoot}`);
try {
await runUnconfiguredSettingsUpgrade(context, ports[0]);
if (arguments_.manageServices) {
await startManagedServices(arguments_.transports);
managedServicesStarted = true;
}
for (const transport of arguments_.transports) {
if (transport === "couchdb") await runCouchDbUpgrade(context, ports);
else await runObjectStorageUpgrade(context, ports);
}
} finally {
await stopSessions(context);
if (managedServicesStarted && !arguments_.keepServices) {
await stopManagedServices(arguments_.transports);
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});