mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Preserve legacy settings across same-profile upgrades
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user