mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-24 05:22:58 +00:00
Prepare the 1.0 compatibility review flow
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import type { SettingsMigrationState } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export const DATABASE_COMPATIBILITY_VERSION_KEY = "database-compatibility-version";
|
||||
export const DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX = "obsidian-live-sync-ver";
|
||||
export const COMPATIBILITY_PAUSE_SETTING_MESSAGE =
|
||||
"Remote synchronisation is paused until this device's compatibility review has been completed.";
|
||||
|
||||
export type DatabaseCompatibilityVersionState = "missing" | "invalid" | "upgrade" | "downgrade";
|
||||
|
||||
export interface DatabaseCompatibilityReason {
|
||||
source: "database-version";
|
||||
state: DatabaseCompatibilityVersionState;
|
||||
acknowledgedVersion?: number;
|
||||
currentVersion: number;
|
||||
resumable: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsCompatibilityReason {
|
||||
source: "settings-schema";
|
||||
sourceVersion: number;
|
||||
currentVersion: number;
|
||||
isFromFutureSchema: boolean;
|
||||
resumable: boolean;
|
||||
}
|
||||
|
||||
export interface LegacyCompatibilityReason {
|
||||
source: "legacy-review";
|
||||
message: string;
|
||||
resumable: true;
|
||||
}
|
||||
|
||||
export type CompatibilityPauseReason =
|
||||
| DatabaseCompatibilityReason
|
||||
| SettingsCompatibilityReason
|
||||
| LegacyCompatibilityReason;
|
||||
|
||||
export interface CompatibilityPause {
|
||||
reasons: readonly CompatibilityPauseReason[];
|
||||
resumable: boolean;
|
||||
}
|
||||
|
||||
export interface CompatibilityEvaluation {
|
||||
pause?: CompatibilityPause;
|
||||
initialiseAcknowledgedVersion: boolean;
|
||||
}
|
||||
|
||||
export interface CompatibilityEvaluationInput {
|
||||
acknowledgedVersion: string | null;
|
||||
currentVersion: number;
|
||||
migrationState?: SettingsMigrationState;
|
||||
legacyReviewMessage: string;
|
||||
}
|
||||
|
||||
function databaseVersionReason(
|
||||
acknowledgedVersion: string | null,
|
||||
currentVersion: number,
|
||||
isNewVault: boolean
|
||||
): DatabaseCompatibilityReason | undefined {
|
||||
if (acknowledgedVersion === null || acknowledgedVersion === "") {
|
||||
if (isNewVault) return undefined;
|
||||
return {
|
||||
source: "database-version",
|
||||
state: "missing",
|
||||
currentVersion,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = Number(acknowledgedVersion);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
return {
|
||||
source: "database-version",
|
||||
state: "invalid",
|
||||
currentVersion,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
if (parsed === currentVersion) return undefined;
|
||||
if (parsed < currentVersion) {
|
||||
return {
|
||||
source: "database-version",
|
||||
state: "upgrade",
|
||||
acknowledgedVersion: parsed,
|
||||
currentVersion,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
source: "database-version",
|
||||
state: "downgrade",
|
||||
acknowledgedVersion: parsed,
|
||||
currentVersion,
|
||||
resumable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a host-neutral compatibility pause from device-local and settings-schema state.
|
||||
*
|
||||
* The caller owns persistence, user interaction, and the actual replication gate.
|
||||
*/
|
||||
export function evaluateCompatibilityPause(input: CompatibilityEvaluationInput): CompatibilityEvaluation {
|
||||
const isNewVault = input.migrationState?.isNewVault === true;
|
||||
const reasons: CompatibilityPauseReason[] = [];
|
||||
const databaseReason = databaseVersionReason(input.acknowledgedVersion, input.currentVersion, isNewVault);
|
||||
if (databaseReason) reasons.push(databaseReason);
|
||||
|
||||
if (input.migrationState?.requiresSyncReview === true) {
|
||||
reasons.push({
|
||||
source: "settings-schema",
|
||||
sourceVersion: input.migrationState.sourceVersion,
|
||||
currentVersion: input.migrationState.targetVersion,
|
||||
isFromFutureSchema: input.migrationState.isFromFutureSchema,
|
||||
resumable: !input.migrationState.isFromFutureSchema,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.legacyReviewMessage !== "" && reasons.length === 0) {
|
||||
reasons.push({
|
||||
source: "legacy-review",
|
||||
message: input.legacyReviewMessage,
|
||||
resumable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reasons.length === 0) {
|
||||
return {
|
||||
initialiseAcknowledgedVersion:
|
||||
isNewVault && (input.acknowledgedVersion === null || input.acknowledgedVersion === ""),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pause: {
|
||||
reasons,
|
||||
resumable: reasons.every((reason) => reason.resumable),
|
||||
},
|
||||
initialiseAcknowledgedVersion: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function legacyDatabaseCompatibilityVersionKey(vaultName: string): string {
|
||||
return `${DATABASE_COMPATIBILITY_LEGACY_VERSION_KEY_PREFIX}${vaultName}`;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { InjectableReplicationService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableReplicationService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { evaluateCompatibilityPause, legacyDatabaseCompatibilityVersionKey } from "./databaseCompatibility.ts";
|
||||
|
||||
function migrationState(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sourceVersion: 2,
|
||||
targetVersion: 2,
|
||||
isNewVault: false,
|
||||
isFromFutureSchema: false,
|
||||
changed: false,
|
||||
requiresSyncReview: false,
|
||||
reviewReasons: [],
|
||||
...overrides,
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("database compatibility evaluation", () => {
|
||||
it("initialises a new Vault without presenting an upgrade review", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: null,
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState({ isNewVault: true }),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ initialiseAcknowledgedVersion: true });
|
||||
});
|
||||
|
||||
it("allows an older acknowledged version to be reviewed and resumed", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "11",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState(),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
|
||||
expect(result.pause).toEqual({
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "database-version",
|
||||
state: "upgrade",
|
||||
acknowledgedVersion: 11,
|
||||
currentVersion: 12,
|
||||
resumable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.initialiseAcknowledgedVersion).toBe(false);
|
||||
});
|
||||
|
||||
it("does not permit an older implementation to acknowledge a newer database version", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "13",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState(),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
|
||||
expect(result.pause?.resumable).toBe(false);
|
||||
expect(result.pause?.reasons[0]).toMatchObject({
|
||||
source: "database-version",
|
||||
state: "downgrade",
|
||||
acknowledgedVersion: 13,
|
||||
currentVersion: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires review when an existing Vault has no valid acknowledged version", () => {
|
||||
for (const acknowledgedVersion of [null, "invalid"]) {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion,
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState(),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
expect(result.pause?.resumable).toBe(true);
|
||||
expect(result.pause?.reasons[0]).toMatchObject({ source: "database-version" });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not permit a future settings schema to be acknowledged by an older implementation", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "12",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState({
|
||||
sourceVersion: 3,
|
||||
targetVersion: 2,
|
||||
isFromFutureSchema: true,
|
||||
requiresSyncReview: true,
|
||||
}),
|
||||
legacyReviewMessage: "",
|
||||
});
|
||||
|
||||
expect(result.pause).toEqual({
|
||||
resumable: false,
|
||||
reasons: [
|
||||
{
|
||||
source: "settings-schema",
|
||||
sourceVersion: 3,
|
||||
currentVersion: 2,
|
||||
isFromFutureSchema: true,
|
||||
resumable: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("retains an existing legacy review when no structured reason can be reconstructed", () => {
|
||||
const result = evaluateCompatibilityPause({
|
||||
acknowledgedVersion: "12",
|
||||
currentVersion: 12,
|
||||
migrationState: migrationState(),
|
||||
legacyReviewMessage: "Review an earlier compatibility change.",
|
||||
});
|
||||
|
||||
expect(result.pause).toEqual({
|
||||
resumable: true,
|
||||
reasons: [
|
||||
{
|
||||
source: "legacy-review",
|
||||
message: "Review an earlier compatibility change.",
|
||||
resumable: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the legacy marker to the Vault", () => {
|
||||
expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault");
|
||||
});
|
||||
});
|
||||
|
||||
describe("packaged Commonlib compatibility gate", () => {
|
||||
it("prevents replication while the compatibility review remains pending", async () => {
|
||||
const openReplication = vi.fn().mockResolvedValue(true);
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const dependencies = {
|
||||
APIService: { isOnline: true, addLog: vi.fn() },
|
||||
appLifecycleService: {
|
||||
isReady: () => true,
|
||||
getUnresolvedMessages: Object.assign(vi.fn().mockResolvedValue([]), { addHandler: vi.fn() }),
|
||||
},
|
||||
databaseService: {},
|
||||
fileProcessingService: { commitPendingFileEvents: vi.fn().mockResolvedValue(true) },
|
||||
replicatorService: {
|
||||
getActiveReplicator: () => ({ openReplication }),
|
||||
runFiniteReplicationActivity,
|
||||
},
|
||||
settingService: {
|
||||
currentSettings: () => ({ versionUpFlash: "Review the database compatibility change." }),
|
||||
},
|
||||
};
|
||||
const service = new InjectableReplicationService(new ServiceContext(), dependencies as never);
|
||||
|
||||
await expect(service.replicate(true)).resolves.toBe(false);
|
||||
|
||||
expect(runFiniteReplicationActivity).not.toHaveBeenCalled();
|
||||
expect(openReplication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user