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