1158 lines
43 KiB
TypeScript
1158 lines
43 KiB
TypeScript
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
|
|
|
||
|
|
import packageJson from '../../../package.json';
|
||
|
|
import {
|
||
|
|
isProviderReadyForSync,
|
||
|
|
type CloudProvider,
|
||
|
|
type ConvergentFieldConflict,
|
||
|
|
type ConvergentProviderBaselineV2,
|
||
|
|
type ConvergentSyncStateV2,
|
||
|
|
type SyncPayload,
|
||
|
|
type SyncResult,
|
||
|
|
type SyncedFile,
|
||
|
|
} from '../../../domain/sync';
|
||
|
|
import {
|
||
|
|
applyLegacySyncPayload,
|
||
|
|
cloudSyncPayloadsEqual,
|
||
|
|
convergentConflictAddressKey,
|
||
|
|
materializeConvergentSyncState,
|
||
|
|
materializeSyncPayloadFromConvergentState,
|
||
|
|
mergeConvergentSyncStates,
|
||
|
|
resolveConvergentFieldConflict,
|
||
|
|
stripConvergentSyncEnvelope,
|
||
|
|
validateConvergentSyncPayload,
|
||
|
|
versionVectorDominates,
|
||
|
|
versionVectorsEqual,
|
||
|
|
withConvergentSyncEnvelope,
|
||
|
|
} from '../../../domain/convergentSync';
|
||
|
|
import type { CloudSyncStrategy } from '../../../domain/syncStrategy';
|
||
|
|
import type { CloudSyncConflictAction } from '../../../domain/syncStrategy';
|
||
|
|
import { detectSuspiciousShrink } from '../../../domain/syncGuards';
|
||
|
|
import { EncryptionService } from '../EncryptionService';
|
||
|
|
import type { CloudAdapter } from '../adapters';
|
||
|
|
|
||
|
|
const CONVERGENT_SYNC_LOCK = 'netcatty-convergent-sync-v2';
|
||
|
|
const MAX_VERIFY_ROUNDS = 3;
|
||
|
|
const MAX_JITTER_MS = 180;
|
||
|
|
|
||
|
|
interface ProviderRuntime {
|
||
|
|
provider: CloudProvider;
|
||
|
|
adapter: CloudAdapter;
|
||
|
|
latestRemote: SyncedFile | null;
|
||
|
|
/** Decrypted payload from the most recent successful download for this cycle. */
|
||
|
|
latestRemotePayload?: SyncPayload;
|
||
|
|
verifiedState?: ConvergentSyncStateV2;
|
||
|
|
verifiedFile?: SyncedFile;
|
||
|
|
/** Decrypted payload retained when the provider is marked verified. */
|
||
|
|
verifiedPayload?: SyncPayload;
|
||
|
|
resourceId?: string;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface DecodedRemote {
|
||
|
|
payload: SyncPayload;
|
||
|
|
state: ConvergentSyncStateV2;
|
||
|
|
file: SyncedFile;
|
||
|
|
}
|
||
|
|
|
||
|
|
function assertSyncSecurityGeneration(manager: any, generation?: number): void {
|
||
|
|
if (typeof manager.assertSyncSecurityGeneration === 'function') {
|
||
|
|
manager.assertSyncSecurityGeneration(generation);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getSyncSecurityGeneration(manager: any): number | undefined {
|
||
|
|
return typeof manager.getSyncSecurityGeneration === 'function'
|
||
|
|
? manager.getSyncSecurityGeneration()
|
||
|
|
: undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function errorMessage(error: unknown): string {
|
||
|
|
return error instanceof Error ? error.message : String(error);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateProviderFailure(
|
||
|
|
manager: any,
|
||
|
|
provider: CloudProvider,
|
||
|
|
error: unknown,
|
||
|
|
): string {
|
||
|
|
const message = errorMessage(error);
|
||
|
|
const reauthHandled = typeof manager.handleProviderReauthRequired === 'function'
|
||
|
|
&& manager.handleProviderReauthRequired(provider, error);
|
||
|
|
if (!reauthHandled) manager.updateProviderStatus(provider, 'error', message);
|
||
|
|
return message;
|
||
|
|
}
|
||
|
|
|
||
|
|
function connectedProviders(manager: any): CloudProvider[] {
|
||
|
|
return (Object.entries(manager.state.providers) as Array<[
|
||
|
|
CloudProvider,
|
||
|
|
Parameters<typeof isProviderReadyForSync>[0],
|
||
|
|
]>)
|
||
|
|
.filter(([, connection]) => isProviderReadyForSync(connection))
|
||
|
|
.map(([provider]) => provider)
|
||
|
|
.sort();
|
||
|
|
}
|
||
|
|
|
||
|
|
function syntheticLegacyDeviceId(provider: CloudProvider, file: SyncedFile): string {
|
||
|
|
return `legacy:${provider}:${file.meta.deviceId}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function decodeRemote(
|
||
|
|
manager: any,
|
||
|
|
provider: CloudProvider,
|
||
|
|
file: SyncedFile,
|
||
|
|
syncSecurityGeneration?: number,
|
||
|
|
): Promise<DecodedRemote> {
|
||
|
|
const payload = await EncryptionService.decryptPayload(file, manager.masterPassword);
|
||
|
|
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
|
||
|
|
const state = validateConvergentSyncPayload(file.meta, payload);
|
||
|
|
if (state) return { payload, state, file };
|
||
|
|
|
||
|
|
const baseline = await manager.loadConvergentProviderBaseline(provider) as
|
||
|
|
| ConvergentProviderBaselineV2
|
||
|
|
| null;
|
||
|
|
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
|
||
|
|
if (!baseline) {
|
||
|
|
throw new Error(
|
||
|
|
`Legacy cloud data from ${provider} has no trusted convergent baseline. Upgrade the old device or choose a version manually.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const legacyPayload = stripConvergentSyncEnvelope(payload);
|
||
|
|
const convertedState = cloudSyncPayloadsEqual(baseline.materializedPayload, legacyPayload)
|
||
|
|
? baseline.state
|
||
|
|
: applyLegacySyncPayload(
|
||
|
|
baseline.state,
|
||
|
|
baseline.materializedPayload,
|
||
|
|
legacyPayload,
|
||
|
|
syntheticLegacyDeviceId(provider, file),
|
||
|
|
file.meta.updatedAt,
|
||
|
|
);
|
||
|
|
return { payload, state: convertedState, file };
|
||
|
|
}
|
||
|
|
|
||
|
|
function mergeStates(
|
||
|
|
initial: ConvergentSyncStateV2,
|
||
|
|
states: Iterable<ConvergentSyncStateV2>,
|
||
|
|
): ConvergentSyncStateV2 {
|
||
|
|
let merged = initial;
|
||
|
|
for (const state of states) merged = mergeConvergentSyncStates(merged, state);
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
|
||
|
|
function materializedPayload(
|
||
|
|
state: ConvergentSyncStateV2,
|
||
|
|
now: number,
|
||
|
|
pluginSidecars?: SyncPayload['pluginSidecars'],
|
||
|
|
): SyncPayload {
|
||
|
|
return materializeSyncPayloadFromConvergentState(state, {
|
||
|
|
syncedAt: now,
|
||
|
|
...(pluginSidecars && Array.isArray(pluginSidecars.entries)
|
||
|
|
? { pluginSidecars }
|
||
|
|
: {}),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function sidecarBundleFromPayload(
|
||
|
|
payload: SyncPayload | null | undefined,
|
||
|
|
): SyncPayload['pluginSidecars'] | undefined {
|
||
|
|
if (!payload || !Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')) {
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
version: 1,
|
||
|
|
entries: Array.isArray(payload.pluginSidecars?.entries)
|
||
|
|
? payload.pluginSidecars.entries
|
||
|
|
: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function currentConflicts(state: ConvergentSyncStateV2): ConvergentFieldConflict[] {
|
||
|
|
return materializeConvergentSyncState(state).conflicts;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function persistReplica(manager: any, state: ConvergentSyncStateV2, now: number): Promise<void> {
|
||
|
|
await manager.saveConvergentReplica({ schemaVersion: 2, state, updatedAt: now });
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateConflictState(manager: any, state: ConvergentSyncStateV2): ConvergentFieldConflict[] {
|
||
|
|
const conflicts = currentConflicts(state);
|
||
|
|
manager.state.convergentConflicts = conflicts;
|
||
|
|
return conflicts;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fullJitter(round: number): Promise<void> {
|
||
|
|
const ceiling = Math.min(MAX_JITTER_MS, 30 * (2 ** round));
|
||
|
|
const delay = Math.floor(Math.random() * (ceiling + 1));
|
||
|
|
if (delay <= 0) return;
|
||
|
|
await new Promise<void>((resolve) => setTimeout(resolve, delay));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function downloadProviderState(
|
||
|
|
manager: any,
|
||
|
|
runtime: ProviderRuntime,
|
||
|
|
syncSecurityGeneration?: number,
|
||
|
|
): Promise<DecodedRemote | null> {
|
||
|
|
const file = await runtime.adapter.download({
|
||
|
|
signal: manager.activeSyncAbortSignal,
|
||
|
|
});
|
||
|
|
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
|
||
|
|
runtime.latestRemote = file;
|
||
|
|
if (!file) {
|
||
|
|
runtime.latestRemotePayload = undefined;
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const decoded = await decodeRemote(manager, runtime.provider, file, syncSecurityGeneration);
|
||
|
|
runtime.latestRemotePayload = decoded.payload;
|
||
|
|
return decoded;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveVerifiedBaseline(
|
||
|
|
manager: any,
|
||
|
|
runtime: ProviderRuntime,
|
||
|
|
decoded: DecodedRemote,
|
||
|
|
): Promise<void> {
|
||
|
|
await manager.saveConvergentProviderBaseline({
|
||
|
|
schemaVersion: 2,
|
||
|
|
provider: runtime.provider,
|
||
|
|
remoteVersion: decoded.file.meta.version,
|
||
|
|
remoteUpdatedAt: decoded.file.meta.updatedAt,
|
||
|
|
remoteDeviceId: decoded.file.meta.deviceId,
|
||
|
|
materializedPayload: stripConvergentSyncEnvelope(decoded.payload),
|
||
|
|
state: decoded.state,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function markProviderVerified(
|
||
|
|
manager: any,
|
||
|
|
runtime: ProviderRuntime,
|
||
|
|
decoded: DecodedRemote,
|
||
|
|
): Promise<void> {
|
||
|
|
runtime.verifiedState = decoded.state;
|
||
|
|
runtime.verifiedFile = decoded.file;
|
||
|
|
runtime.verifiedPayload = decoded.payload;
|
||
|
|
runtime.latestRemotePayload = decoded.payload;
|
||
|
|
runtime.error = undefined;
|
||
|
|
await saveVerifiedBaseline(manager, runtime, decoded);
|
||
|
|
|
||
|
|
const connection = manager.state.providers[runtime.provider];
|
||
|
|
manager.state.providers[runtime.provider] = {
|
||
|
|
...connection,
|
||
|
|
resourceId: runtime.resourceId || connection.resourceId,
|
||
|
|
lastSync: Date.now(),
|
||
|
|
lastSyncVersion: decoded.file.meta.version,
|
||
|
|
};
|
||
|
|
manager.state.remoteVersion = Math.max(manager.state.remoteVersion, decoded.file.meta.version);
|
||
|
|
manager.state.remoteUpdatedAt = Math.max(manager.state.remoteUpdatedAt, decoded.file.meta.updatedAt);
|
||
|
|
if (typeof manager.saveProviderConnection === 'function') {
|
||
|
|
await manager.saveProviderConnection(runtime.provider, manager.state.providers[runtime.provider]);
|
||
|
|
}
|
||
|
|
manager.updateProviderStatus(runtime.provider, 'connected');
|
||
|
|
}
|
||
|
|
|
||
|
|
function failedResult(provider: CloudProvider, error: string): SyncResult {
|
||
|
|
return { success: false, provider, action: 'none', error };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runInitialDownloads(
|
||
|
|
manager: any,
|
||
|
|
providers: CloudProvider[],
|
||
|
|
syncSecurityGeneration?: number,
|
||
|
|
announce = true,
|
||
|
|
): Promise<ProviderRuntime[]> {
|
||
|
|
return Promise.all(providers.map(async (provider): Promise<ProviderRuntime> => {
|
||
|
|
if (announce) {
|
||
|
|
manager.updateProviderStatus(provider, 'syncing');
|
||
|
|
manager.emit({ type: 'SYNC_STARTED', provider });
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const adapter = await manager.getConnectedAdapter(provider) as CloudAdapter;
|
||
|
|
assertSyncSecurityGeneration(manager, syncSecurityGeneration);
|
||
|
|
const runtime: ProviderRuntime = { provider, adapter, latestRemote: null };
|
||
|
|
try {
|
||
|
|
const decoded = await downloadProviderState(manager, runtime, syncSecurityGeneration);
|
||
|
|
if (decoded) {
|
||
|
|
runtime.verifiedState = decoded.state;
|
||
|
|
runtime.verifiedPayload = decoded.payload;
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
runtime.error = errorMessage(error);
|
||
|
|
}
|
||
|
|
return runtime;
|
||
|
|
} catch (error) {
|
||
|
|
return {
|
||
|
|
provider,
|
||
|
|
adapter: undefined as unknown as CloudAdapter,
|
||
|
|
latestRemote: null,
|
||
|
|
error: errorMessage(error),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PreparedLocalState {
|
||
|
|
canonical: ConvergentSyncStateV2;
|
||
|
|
durableBeforeVerification: ConvergentSyncStateV2;
|
||
|
|
}
|
||
|
|
|
||
|
|
function prepareLocalState(
|
||
|
|
strategy: CloudSyncStrategy,
|
||
|
|
replica: ConvergentSyncStateV2,
|
||
|
|
localPayload: SyncPayload,
|
||
|
|
remoteStates: ConvergentSyncStateV2[],
|
||
|
|
deviceId: string,
|
||
|
|
now: number,
|
||
|
|
): PreparedLocalState {
|
||
|
|
const localBaseline = materializedPayload(replica, now);
|
||
|
|
if (strategy === 'preferCloud' && remoteStates.length > 0) {
|
||
|
|
const [first, ...rest] = remoteStates;
|
||
|
|
return {
|
||
|
|
canonical: mergeStates(first, rest),
|
||
|
|
durableBeforeVerification: replica,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
if (strategy === 'preferLocal') {
|
||
|
|
const joined = mergeStates(replica, remoteStates);
|
||
|
|
const canonical = applyLegacySyncPayload(
|
||
|
|
joined,
|
||
|
|
localBaseline,
|
||
|
|
stripConvergentSyncEnvelope(localPayload),
|
||
|
|
deviceId,
|
||
|
|
now,
|
||
|
|
);
|
||
|
|
return { canonical, durableBeforeVerification: canonical };
|
||
|
|
}
|
||
|
|
const withLocalWrites = applyLegacySyncPayload(
|
||
|
|
replica,
|
||
|
|
localBaseline,
|
||
|
|
stripConvergentSyncEnvelope(localPayload),
|
||
|
|
deviceId,
|
||
|
|
now,
|
||
|
|
);
|
||
|
|
return {
|
||
|
|
canonical: mergeStates(withLocalWrites, remoteStates),
|
||
|
|
durableBeforeVerification: withLocalWrites,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The unlocked implementation is exported for deterministic integration
|
||
|
|
* tests. Production callers must use `syncAllProvidersConvergentlyImpl`, which
|
||
|
|
* holds the cross-window Web Lock.
|
||
|
|
*/
|
||
|
|
export async function syncConvergentProvidersUnlockedImpl(
|
||
|
|
this: any,
|
||
|
|
inputPayload: SyncPayload,
|
||
|
|
options: {
|
||
|
|
maxRounds?: number;
|
||
|
|
now?: () => number;
|
||
|
|
jitter?: (round: number) => Promise<void>;
|
||
|
|
strategyOverride?: CloudSyncStrategy;
|
||
|
|
overrideShrink?: boolean;
|
||
|
|
applyPayload?: (
|
||
|
|
payload: SyncPayload,
|
||
|
|
commitReplica: () => Promise<void>,
|
||
|
|
) => Promise<void>;
|
||
|
|
} = {},
|
||
|
|
): Promise<Map<CloudProvider, SyncResult>> {
|
||
|
|
const results = new Map<CloudProvider, SyncResult>();
|
||
|
|
const providers = connectedProviders(this);
|
||
|
|
if (providers.length === 0) return results;
|
||
|
|
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||
|
|
for (const provider of providers) results.set(provider, failedResult(provider, 'Vault is locked'));
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = options.now ?? Date.now;
|
||
|
|
const jitter = options.jitter ?? fullJitter;
|
||
|
|
const maxRounds = options.maxRounds ?? MAX_VERIFY_ROUNDS;
|
||
|
|
const syncSecurityGeneration = getSyncSecurityGeneration(this);
|
||
|
|
this.state.syncState = 'SYNCING';
|
||
|
|
this.state.lastError = null;
|
||
|
|
this.state.currentConflict = null;
|
||
|
|
|
||
|
|
const replica = await this.loadConvergentReplica();
|
||
|
|
if (!replica) {
|
||
|
|
const message = 'Convergent sync is initialized but its local replica is missing';
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = message;
|
||
|
|
for (const provider of providers) results.set(provider, failedResult(provider, message));
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
const strategy = options.strategyOverride ?? this.state.syncStrategy;
|
||
|
|
const replicaPayload = materializedPayload(replica.state, now());
|
||
|
|
if (strategy !== 'preferCloud') {
|
||
|
|
const finding = detectSuspiciousShrink(
|
||
|
|
stripConvergentSyncEnvelope(inputPayload),
|
||
|
|
replicaPayload,
|
||
|
|
null,
|
||
|
|
);
|
||
|
|
if (finding.suspicious && !options.overrideShrink) {
|
||
|
|
this.state.syncState = 'BLOCKED';
|
||
|
|
this.state.lastShrinkFinding = finding;
|
||
|
|
for (const provider of providers) {
|
||
|
|
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||
|
|
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
|
||
|
|
results.set(provider, {
|
||
|
|
success: false,
|
||
|
|
provider,
|
||
|
|
action: 'none',
|
||
|
|
shrinkBlocked: true,
|
||
|
|
finding,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
if (finding.suspicious) {
|
||
|
|
for (const provider of providers) {
|
||
|
|
this.emit({ type: 'SYNC_FORCED', provider, finding });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const runtimes = await runInitialDownloads(this, providers, syncSecurityGeneration);
|
||
|
|
// Adapter acquisition failures are terminal for this cycle. Download,
|
||
|
|
// decrypt, and verification failures remain retryable in later rounds.
|
||
|
|
const usable = runtimes.filter((runtime) => Boolean(runtime.adapter));
|
||
|
|
const preparedLocal = prepareLocalState(
|
||
|
|
strategy,
|
||
|
|
replica.state,
|
||
|
|
inputPayload,
|
||
|
|
usable.flatMap((runtime) => runtime.verifiedState ? [runtime.verifiedState] : []),
|
||
|
|
this.state.deviceId,
|
||
|
|
now(),
|
||
|
|
);
|
||
|
|
let canonical = preparedLocal.canonical;
|
||
|
|
const durableBeforeVerification = preparedLocal.durableBeforeVerification;
|
||
|
|
|
||
|
|
// Persist locally-generated dots before every network write, but do not
|
||
|
|
// commit downloaded remote-only dots until at least one provider verifies
|
||
|
|
// the joined state. A total network failure must leave the replica aligned
|
||
|
|
// with the still-unmodified renderer vault.
|
||
|
|
await persistReplica(this, durableBeforeVerification, now());
|
||
|
|
updateConflictState(this, durableBeforeVerification);
|
||
|
|
|
||
|
|
// Capture pre-cycle sidecar baselines per provider (never union across
|
||
|
|
// providers). Cross-provider union invents a shared base that can treat one
|
||
|
|
// provider's empty remote as deleting another provider's retained settings.
|
||
|
|
const cycleBaseByProvider = new Map<CloudProvider, NonNullable<SyncPayload['pluginSidecars']>['entries']>();
|
||
|
|
if (typeof this.loadConvergentProviderBaseline === 'function') {
|
||
|
|
for (const runtime of usable) {
|
||
|
|
try {
|
||
|
|
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
|
||
|
|
materializedPayload?: SyncPayload;
|
||
|
|
} | null;
|
||
|
|
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
|
||
|
|
if (!baseBundle || !Array.isArray(baseBundle.entries)) continue;
|
||
|
|
cycleBaseByProvider.set(runtime.provider, baseBundle.entries);
|
||
|
|
} catch {
|
||
|
|
// First cycle or missing baseline is fine.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (let round = 0; round < maxRounds && usable.length > 0; round += 1) {
|
||
|
|
if (round > 0) await jitter(round);
|
||
|
|
|
||
|
|
const preflight = await Promise.all(usable.map(async (runtime) => {
|
||
|
|
try {
|
||
|
|
const decoded = await downloadProviderState(this, runtime, syncSecurityGeneration);
|
||
|
|
runtime.error = undefined;
|
||
|
|
return decoded;
|
||
|
|
} catch (error) {
|
||
|
|
runtime.error = errorMessage(error);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
canonical = mergeStates(canonical, preflight.flatMap((decoded) => decoded ? [decoded.state] : []));
|
||
|
|
|
||
|
|
const expected = canonical;
|
||
|
|
const preflightVerified = new Map<CloudProvider, DecodedRemote>();
|
||
|
|
await Promise.all(usable.map(async (runtime, index) => {
|
||
|
|
if (runtime.error) return;
|
||
|
|
const decoded = preflight[index];
|
||
|
|
if (
|
||
|
|
!decoded
|
||
|
|
|| decoded.file.meta.syncSchemaVersion !== 2
|
||
|
|
|| !versionVectorDominates(decoded.state.vector, expected.vector)
|
||
|
|
) return;
|
||
|
|
try {
|
||
|
|
// The provider already contains the complete joined state. Treat the
|
||
|
|
// preflight read as verification instead of creating a new encrypted
|
||
|
|
// cloud revision for a no-op runtime check.
|
||
|
|
await markProviderVerified(this, runtime, decoded);
|
||
|
|
preflightVerified.set(runtime.provider, decoded);
|
||
|
|
} catch (error) {
|
||
|
|
runtime.error = errorMessage(error);
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
// Merge plugin sidecars per successful provider with that provider's own
|
||
|
|
// pre-cycle baseline. Failed providers are skipped so their baselines
|
||
|
|
// cannot invent deletions against another provider's empty remote.
|
||
|
|
const {
|
||
|
|
mergePluginSyncSidecarsThreeWay,
|
||
|
|
} = await import('../../../domain/pluginSyncSidecar');
|
||
|
|
const localSidecarEntries = Array.isArray(inputPayload.pluginSidecars?.entries)
|
||
|
|
? inputPayload.pluginSidecars.entries
|
||
|
|
: [];
|
||
|
|
let mergedSidecars = [...localSidecarEntries];
|
||
|
|
for (const runtime of usable) {
|
||
|
|
if (runtime.error) continue;
|
||
|
|
const fromPreflight = preflightVerified.get(runtime.provider)?.payload?.pluginSidecars;
|
||
|
|
const fromLatest = runtime.latestRemotePayload?.pluginSidecars;
|
||
|
|
const remoteBundle = fromPreflight ?? fromLatest;
|
||
|
|
if (!remoteBundle) continue;
|
||
|
|
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
|
||
|
|
const sidecarStrategy =
|
||
|
|
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
|
||
|
|
mergedSidecars = mergePluginSyncSidecarsThreeWay({
|
||
|
|
base: baseForProvider,
|
||
|
|
local: mergedSidecars,
|
||
|
|
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||
|
|
strategy: sidecarStrategy,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const pluginSidecars = mergedSidecars.length > 0
|
||
|
|
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
|
||
|
|
? { version: 1 as const, entries: mergedSidecars }
|
||
|
|
: inputPayload.pluginSidecars;
|
||
|
|
const sidecarFingerprint = (
|
||
|
|
bundle: typeof pluginSidecars,
|
||
|
|
payload?: SyncPayload | null,
|
||
|
|
) => {
|
||
|
|
const present = payload
|
||
|
|
? Object.prototype.hasOwnProperty.call(payload, 'pluginSidecars')
|
||
|
|
: bundle != null && Array.isArray(bundle.entries);
|
||
|
|
const entries = (bundle?.entries ?? [])
|
||
|
|
.map((e) => [e.pluginId, e.kind, e.key, e.updatedAt, e.value] as const)
|
||
|
|
.sort((a, b) => {
|
||
|
|
if (a[0] !== b[0]) return String(a[0]).localeCompare(String(b[0]));
|
||
|
|
if (a[1] !== b[1]) return String(a[1]).localeCompare(String(b[1]));
|
||
|
|
return String(a[2]).localeCompare(String(b[2]));
|
||
|
|
});
|
||
|
|
// Empty present and empty absent both fingerprint as empty so devices
|
||
|
|
// do not ping-pong uploading omitted vs explicit-empty markers.
|
||
|
|
if (entries.length === 0) {
|
||
|
|
return JSON.stringify({ present: false, version: null, entries: [] });
|
||
|
|
}
|
||
|
|
return JSON.stringify({
|
||
|
|
present,
|
||
|
|
version: present ? (bundle?.version ?? 1) : null,
|
||
|
|
entries,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
const localSidecarFp = sidecarFingerprint(
|
||
|
|
pluginSidecars,
|
||
|
|
Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars') || mergedSidecars.length > 0
|
||
|
|
? { ...inputPayload, pluginSidecars }
|
||
|
|
: inputPayload,
|
||
|
|
);
|
||
|
|
const providerSidecarMismatch = (runtime: { provider: string }) => {
|
||
|
|
const decoded = preflightVerified.get(runtime.provider);
|
||
|
|
if (!decoded) return true; // no preflight match → need upload
|
||
|
|
return sidecarFingerprint(decoded.payload?.pluginSidecars, decoded.payload) !== localSidecarFp;
|
||
|
|
};
|
||
|
|
const remoteSidecarMismatch = usable.some((runtime) => {
|
||
|
|
if (runtime.error) return false;
|
||
|
|
return providerSidecarMismatch(runtime);
|
||
|
|
});
|
||
|
|
const needsUpload = usable.some((runtime) => (
|
||
|
|
!runtime.error && !preflightVerified.has(runtime.provider)
|
||
|
|
)) || remoteSidecarMismatch;
|
||
|
|
const outgoingPayload = needsUpload
|
||
|
|
? withConvergentSyncEnvelope(expected, {
|
||
|
|
syncedAt: now(),
|
||
|
|
pluginSidecars,
|
||
|
|
})
|
||
|
|
: null;
|
||
|
|
await Promise.all(usable.map(async (runtime) => {
|
||
|
|
// Upload when CRDT preflight missed OR sidecars diverge for this provider.
|
||
|
|
if (runtime.error) return;
|
||
|
|
if (preflightVerified.has(runtime.provider) && !providerSidecarMismatch(runtime)) return;
|
||
|
|
if (!outgoingPayload) return;
|
||
|
|
try {
|
||
|
|
const remoteVersion = runtime.latestRemote?.meta.version ?? this.state.localVersion;
|
||
|
|
const file = await EncryptionService.encryptPayload(
|
||
|
|
outgoingPayload!,
|
||
|
|
this.masterPassword,
|
||
|
|
this.state.deviceId,
|
||
|
|
this.state.deviceName,
|
||
|
|
packageJson.version,
|
||
|
|
remoteVersion,
|
||
|
|
);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
runtime.resourceId = await runtime.adapter.upload(file, {
|
||
|
|
signal: this.activeSyncAbortSignal,
|
||
|
|
});
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
runtime.latestRemote = file;
|
||
|
|
// Sidecar-only (or any) upload invalidates preflight verification so the
|
||
|
|
// post-write payload is re-downloaded and baselines stay current.
|
||
|
|
preflightVerified.delete(runtime.provider);
|
||
|
|
runtime.verifiedState = undefined;
|
||
|
|
runtime.verifiedPayload = undefined;
|
||
|
|
runtime.verifiedFile = undefined;
|
||
|
|
} catch (error) {
|
||
|
|
runtime.error = errorMessage(error);
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
|
||
|
|
const verified = await Promise.all(usable.map(async (runtime) => {
|
||
|
|
if (runtime.error) return null;
|
||
|
|
const preflightDecoded = preflightVerified.get(runtime.provider);
|
||
|
|
if (preflightDecoded) return preflightDecoded;
|
||
|
|
try {
|
||
|
|
const decoded = await downloadProviderState(this, runtime, syncSecurityGeneration);
|
||
|
|
if (!decoded) throw new Error('Provider returned no file after upload');
|
||
|
|
if (!versionVectorDominates(decoded.state.vector, expected.vector)) {
|
||
|
|
throw new Error('Remote verification did not retain the expected convergent state');
|
||
|
|
}
|
||
|
|
await markProviderVerified(this, runtime, decoded);
|
||
|
|
return decoded;
|
||
|
|
} catch (error) {
|
||
|
|
runtime.error = errorMessage(error);
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
|
||
|
|
canonical = mergeStates(canonical, verified.flatMap((decoded) => decoded ? [decoded.state] : []));
|
||
|
|
|
||
|
|
const activeVerified = usable.filter((runtime) => runtime.verifiedState && !runtime.error);
|
||
|
|
if (
|
||
|
|
activeVerified.length === usable.length
|
||
|
|
&& activeVerified.every((runtime) => versionVectorDominates(
|
||
|
|
runtime.verifiedState!.vector,
|
||
|
|
canonical.vector,
|
||
|
|
))
|
||
|
|
&& versionVectorsEqual(expected.vector, canonical.vector)
|
||
|
|
) break;
|
||
|
|
}
|
||
|
|
|
||
|
|
const successfulRuntimes = runtimes.filter((runtime) => (
|
||
|
|
runtime.verifiedState
|
||
|
|
&& !runtime.error
|
||
|
|
&& versionVectorDominates(runtime.verifiedState.vector, canonical.vector)
|
||
|
|
));
|
||
|
|
const hasSuccess = successfulRuntimes.length > 0;
|
||
|
|
const {
|
||
|
|
mergePluginSyncSidecarsThreeWay: mergeSidecarsThreeWayFinal,
|
||
|
|
} = await import('../../../domain/pluginSyncSidecar');
|
||
|
|
const finalLocalSidecars = Array.isArray(inputPayload.pluginSidecars?.entries)
|
||
|
|
? [...inputPayload.pluginSidecars.entries]
|
||
|
|
: [];
|
||
|
|
let finalSidecarEntries = [...finalLocalSidecars];
|
||
|
|
for (const runtime of successfulRuntimes) {
|
||
|
|
const remoteBundle = runtime.verifiedPayload?.pluginSidecars
|
||
|
|
?? runtime.latestRemotePayload?.pluginSidecars;
|
||
|
|
if (!remoteBundle) continue;
|
||
|
|
const baseForProvider = cycleBaseByProvider.get(runtime.provider) ?? [];
|
||
|
|
const finalSidecarStrategy =
|
||
|
|
strategy === 'preferCloud' || strategy === 'preferLocal' ? strategy : 'smart';
|
||
|
|
finalSidecarEntries = mergeSidecarsThreeWayFinal({
|
||
|
|
base: baseForProvider,
|
||
|
|
local: finalSidecarEntries,
|
||
|
|
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||
|
|
strategy: finalSidecarStrategy,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const finalPluginSidecars = finalSidecarEntries.length > 0
|
||
|
|
|| Object.prototype.hasOwnProperty.call(inputPayload, 'pluginSidecars')
|
||
|
|
? { version: 1 as const, entries: finalSidecarEntries }
|
||
|
|
: undefined;
|
||
|
|
const mergedPayloadBase = materializedPayload(canonical, now());
|
||
|
|
const mergedPayload: SyncPayload = {
|
||
|
|
...mergedPayloadBase,
|
||
|
|
...(finalPluginSidecars ? { pluginSidecars: finalPluginSidecars } : {}),
|
||
|
|
};
|
||
|
|
const localPayloadChanged = !cloudSyncPayloadsEqual(inputPayload, mergedPayload)
|
||
|
|
|| JSON.stringify(inputPayload.pluginSidecars ?? null)
|
||
|
|
!== JSON.stringify(mergedPayload.pluginSidecars ?? null);
|
||
|
|
let mergedPayloadApplied = false;
|
||
|
|
if (hasSuccess && localPayloadChanged) {
|
||
|
|
try {
|
||
|
|
if (!options.applyPayload) {
|
||
|
|
throw new Error(
|
||
|
|
'Convergent sync produced remote changes without a protected payload applier',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
let committed = false;
|
||
|
|
await options.applyPayload(mergedPayload, async () => {
|
||
|
|
if (committed) return;
|
||
|
|
await persistReplica(this, canonical, now());
|
||
|
|
updateConflictState(this, canonical);
|
||
|
|
committed = true;
|
||
|
|
});
|
||
|
|
if (!committed) {
|
||
|
|
throw new Error('Convergent payload apply completed without committing its replica');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = errorMessage(error);
|
||
|
|
this.notifyStateChange();
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
mergedPayloadApplied = true;
|
||
|
|
} else if (hasSuccess) {
|
||
|
|
await persistReplica(this, canonical, now());
|
||
|
|
updateConflictState(this, canonical);
|
||
|
|
} else {
|
||
|
|
await persistReplica(this, durableBeforeVerification, now());
|
||
|
|
updateConflictState(this, durableBeforeVerification);
|
||
|
|
}
|
||
|
|
const conflicts = currentConflicts(hasSuccess ? canonical : durableBeforeVerification);
|
||
|
|
for (const runtime of runtimes) {
|
||
|
|
if (
|
||
|
|
runtime.verifiedState
|
||
|
|
&& !runtime.error
|
||
|
|
&& versionVectorDominates(runtime.verifiedState.vector, canonical.vector)
|
||
|
|
) {
|
||
|
|
const result: SyncResult = {
|
||
|
|
success: true,
|
||
|
|
provider: runtime.provider,
|
||
|
|
action: 'merge',
|
||
|
|
version: runtime.verifiedFile?.meta.version ?? runtime.latestRemote?.meta.version,
|
||
|
|
...(localPayloadChanged ? { mergedPayload } : {}),
|
||
|
|
...(mergedPayloadApplied ? { mergedPayloadApplied: true } : {}),
|
||
|
|
convergentConflicts: conflicts,
|
||
|
|
convergentConflictCount: conflicts.length,
|
||
|
|
};
|
||
|
|
results.set(runtime.provider, result);
|
||
|
|
this.emit({ type: 'SYNC_COMPLETED', provider: runtime.provider, result });
|
||
|
|
this.addSyncHistoryEntry({
|
||
|
|
timestamp: now(),
|
||
|
|
provider: runtime.provider,
|
||
|
|
action: 'merge',
|
||
|
|
success: true,
|
||
|
|
localVersion: result.version ?? this.state.localVersion,
|
||
|
|
remoteVersion: result.version,
|
||
|
|
deviceName: this.state.deviceName,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
const message = runtime.error ?? `Provider did not converge after ${maxRounds} verification rounds`;
|
||
|
|
runtime.error = message;
|
||
|
|
results.set(runtime.provider, failedResult(runtime.provider, message));
|
||
|
|
updateProviderFailure(this, runtime.provider, message);
|
||
|
|
this.emit({ type: 'SYNC_ERROR', provider: runtime.provider, error: message });
|
||
|
|
this.addSyncHistoryEntry({
|
||
|
|
timestamp: now(),
|
||
|
|
provider: runtime.provider,
|
||
|
|
action: 'merge',
|
||
|
|
success: false,
|
||
|
|
localVersion: this.state.localVersion,
|
||
|
|
deviceName: this.state.deviceName,
|
||
|
|
error: message,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.state.localVersion = Math.max(
|
||
|
|
this.state.localVersion,
|
||
|
|
...runtimes.map((runtime) => runtime.verifiedFile?.meta.version ?? 0),
|
||
|
|
);
|
||
|
|
this.state.localUpdatedAt = now();
|
||
|
|
this.state.pendingLocalSync = !runtimes.every((runtime) => results.get(runtime.provider)?.success);
|
||
|
|
if (hasSuccess) this.exitBlockedState();
|
||
|
|
this.state.syncState = hasSuccess ? 'IDLE' : 'ERROR';
|
||
|
|
this.state.lastError = hasSuccess
|
||
|
|
? null
|
||
|
|
: runtimes.find((runtime) => runtime.error)?.error ?? 'Convergent sync failed';
|
||
|
|
if (hasSuccess) {
|
||
|
|
this.state.lastShrinkFinding = undefined;
|
||
|
|
}
|
||
|
|
this.saveSyncConfig();
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function withConvergentSyncWebLock<T>(task: () => Promise<T>): Promise<T> {
|
||
|
|
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||
|
|
if (!locks) {
|
||
|
|
throw new Error('Convergent sync requires the Web Locks API to prevent concurrent window writes');
|
||
|
|
}
|
||
|
|
return locks.request(
|
||
|
|
CONVERGENT_SYNC_LOCK,
|
||
|
|
{ mode: 'exclusive', ifAvailable: true },
|
||
|
|
async (lock) => {
|
||
|
|
if (!lock) throw new Error('Convergent sync is already running in another window');
|
||
|
|
return task();
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function syncAllProvidersConvergentlyImpl(
|
||
|
|
this: any,
|
||
|
|
inputPayload: SyncPayload,
|
||
|
|
opts: {
|
||
|
|
conflictActionOverride?: CloudSyncConflictAction;
|
||
|
|
overrideShrink?: boolean;
|
||
|
|
applyPayload?: (
|
||
|
|
payload: SyncPayload,
|
||
|
|
commitReplica: () => Promise<void>,
|
||
|
|
) => Promise<void>;
|
||
|
|
} = {},
|
||
|
|
): Promise<Map<CloudProvider, SyncResult>> {
|
||
|
|
try {
|
||
|
|
const strategyOverride = opts.conflictActionOverride === 'upload-local'
|
||
|
|
? 'preferLocal'
|
||
|
|
: opts.conflictActionOverride === 'download-remote'
|
||
|
|
? 'preferCloud'
|
||
|
|
: undefined;
|
||
|
|
return await withConvergentSyncWebLock(
|
||
|
|
() => syncConvergentProvidersUnlockedImpl.call(this, inputPayload, {
|
||
|
|
strategyOverride,
|
||
|
|
overrideShrink: opts.overrideShrink,
|
||
|
|
applyPayload: opts.applyPayload,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
const message = errorMessage(error);
|
||
|
|
const results = new Map<CloudProvider, SyncResult>();
|
||
|
|
for (const provider of connectedProviders(this)) {
|
||
|
|
results.set(provider, failedResult(provider, message));
|
||
|
|
updateProviderFailure(this, provider, error);
|
||
|
|
this.emit({ type: 'SYNC_ERROR', provider, error: message });
|
||
|
|
}
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = message;
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function previewConvergentRecoveryImpl(
|
||
|
|
this: any,
|
||
|
|
): Promise<SyncPayload | null> {
|
||
|
|
return withConvergentSyncWebLock(async () => {
|
||
|
|
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||
|
|
throw new Error('Vault is locked');
|
||
|
|
}
|
||
|
|
const providers = connectedProviders(this);
|
||
|
|
if (providers.length === 0) return null;
|
||
|
|
const syncSecurityGeneration = getSyncSecurityGeneration(this);
|
||
|
|
const runtimes = await runInitialDownloads(
|
||
|
|
this,
|
||
|
|
providers,
|
||
|
|
syncSecurityGeneration,
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
const failed = runtimes.find((runtime) => runtime.error || !runtime.adapter);
|
||
|
|
if (failed) {
|
||
|
|
const message = failed.error ?? 'provider adapter unavailable';
|
||
|
|
updateProviderFailure(this, failed.provider, message);
|
||
|
|
throw new Error(`Recovery preflight failed for ${failed.provider}: ${message}`);
|
||
|
|
}
|
||
|
|
const remoteStates = runtimes.flatMap((runtime) => (
|
||
|
|
runtime.verifiedState ? [runtime.verifiedState] : []
|
||
|
|
));
|
||
|
|
if (remoteStates.length === 0) return null;
|
||
|
|
const [first, ...rest] = remoteStates;
|
||
|
|
return materializedPayload(mergeStates(first, rest), Date.now());
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function prepareConvergentConflictResolutionImpl(
|
||
|
|
this: any,
|
||
|
|
addressKey: string,
|
||
|
|
candidateDot: string,
|
||
|
|
now = Date.now(),
|
||
|
|
pluginSidecars?: SyncPayload['pluginSidecars'],
|
||
|
|
): Promise<{ state: ConvergentSyncStateV2; payload: SyncPayload; now: number }> {
|
||
|
|
const replica = await this.loadConvergentReplica();
|
||
|
|
if (!replica) throw new Error('Convergent sync replica is unavailable');
|
||
|
|
const conflict = currentConflicts(replica.state).find(
|
||
|
|
(entry) => convergentConflictAddressKey(entry.address) === addressKey,
|
||
|
|
);
|
||
|
|
if (!conflict) throw new Error('The convergent conflict no longer exists');
|
||
|
|
const resolved = resolveConvergentFieldConflict(
|
||
|
|
replica.state,
|
||
|
|
conflict,
|
||
|
|
candidateDot,
|
||
|
|
this.state.deviceId,
|
||
|
|
now,
|
||
|
|
);
|
||
|
|
// Prefer caller-provided current sidecars. Never fall back to a stale
|
||
|
|
// provider baseline — that would revert plugin settings changed after the
|
||
|
|
// last verified sync. When omitted, materialize without the field so apply
|
||
|
|
// preserves local sidecars (legacy missing-field semantics).
|
||
|
|
return {
|
||
|
|
state: resolved,
|
||
|
|
payload: materializedPayload(resolved, now, pluginSidecars),
|
||
|
|
now,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function resolveConvergentConflictAndSyncImpl(
|
||
|
|
this: any,
|
||
|
|
addressKey: string,
|
||
|
|
candidateDot: string,
|
||
|
|
applyPayload: (
|
||
|
|
payload: SyncPayload,
|
||
|
|
commitReplica: () => Promise<void>,
|
||
|
|
) => Promise<void>,
|
||
|
|
options?: {
|
||
|
|
pluginSidecars?: SyncPayload['pluginSidecars'];
|
||
|
|
},
|
||
|
|
): Promise<{ payload: SyncPayload; results: Map<CloudProvider, SyncResult> }> {
|
||
|
|
return withConvergentSyncWebLock(async () => {
|
||
|
|
const prepared = await prepareConvergentConflictResolutionImpl.call(
|
||
|
|
this,
|
||
|
|
addressKey,
|
||
|
|
candidateDot,
|
||
|
|
Date.now(),
|
||
|
|
options?.pluginSidecars,
|
||
|
|
);
|
||
|
|
let committed = false;
|
||
|
|
await applyPayload(prepared.payload, async () => {
|
||
|
|
if (committed) return;
|
||
|
|
await persistReplica(this, prepared.state, prepared.now);
|
||
|
|
updateConflictState(this, prepared.state);
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.notifyStateChange();
|
||
|
|
committed = true;
|
||
|
|
});
|
||
|
|
if (!committed) {
|
||
|
|
throw new Error('Convergent conflict apply completed without committing its replica');
|
||
|
|
}
|
||
|
|
const results = await syncConvergentProvidersUnlockedImpl.call(
|
||
|
|
this,
|
||
|
|
prepared.payload,
|
||
|
|
{ applyPayload },
|
||
|
|
);
|
||
|
|
const finalReplica = await this.loadConvergentReplica();
|
||
|
|
if (!finalReplica) {
|
||
|
|
throw new Error('Convergent sync replica disappeared after conflict propagation');
|
||
|
|
}
|
||
|
|
const finalPayload = materializedPayload(
|
||
|
|
finalReplica.state,
|
||
|
|
Date.now(),
|
||
|
|
sidecarBundleFromPayload(prepared.payload),
|
||
|
|
);
|
||
|
|
return { payload: finalPayload, results };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function downgradeConvergentSyncImpl(
|
||
|
|
this: any,
|
||
|
|
confirmed: boolean,
|
||
|
|
buildLocalPayload: () => SyncPayload | Promise<SyncPayload>,
|
||
|
|
applyPayload: (
|
||
|
|
payload: SyncPayload,
|
||
|
|
commitReplica: () => Promise<void>,
|
||
|
|
) => Promise<void>,
|
||
|
|
): Promise<Map<CloudProvider, SyncResult>> {
|
||
|
|
if (!confirmed) throw new Error('Explicit confirmation is required to downgrade convergent sync');
|
||
|
|
return withConvergentSyncWebLock(async () => {
|
||
|
|
const syncSecurityGeneration = getSyncSecurityGeneration(this);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
const providers = connectedProviders(this);
|
||
|
|
const replica = await this.loadConvergentReplica();
|
||
|
|
if (!replica) throw new Error('Convergent sync replica is unavailable');
|
||
|
|
const localPayload = await buildLocalPayload();
|
||
|
|
const localSidecars = sidecarBundleFromPayload(localPayload);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
const results = new Map<CloudProvider, SyncResult>();
|
||
|
|
this.state.syncState = 'SYNCING';
|
||
|
|
this.state.lastError = null;
|
||
|
|
const runtimes = await runInitialDownloads(this, providers, syncSecurityGeneration);
|
||
|
|
const failedPreflight = runtimes.find((runtime) => runtime.error || !runtime.adapter);
|
||
|
|
if (failedPreflight) {
|
||
|
|
const message = `Downgrade preflight failed for ${failedPreflight.provider}: ${failedPreflight.error ?? 'provider adapter unavailable'}`;
|
||
|
|
for (const provider of providers) {
|
||
|
|
results.set(provider, failedResult(provider, message));
|
||
|
|
if (provider === failedPreflight.provider) {
|
||
|
|
updateProviderFailure(this, provider, failedPreflight.error ?? message);
|
||
|
|
} else {
|
||
|
|
this.updateProviderStatus(provider, 'error', message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = message;
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
const now = Date.now();
|
||
|
|
// Pausing v2 stops synchronization, not local editing. Convert every
|
||
|
|
// vault change made since the last persisted replica into causal local
|
||
|
|
// writes before joining provider states; otherwise downgrade would
|
||
|
|
// materialize the stale replica and overwrite those paused edits.
|
||
|
|
// Prefer local sidecars, then three-way-merge each remote against that
|
||
|
|
// provider's trusted baseline so explicit local resets are not resurrected
|
||
|
|
// by a stale remote entry during downgrade.
|
||
|
|
let downgradeSidecars = localSidecars;
|
||
|
|
{
|
||
|
|
const { mergePluginSyncSidecarsThreeWay } = await import('../../../domain/pluginSyncSidecar');
|
||
|
|
let entries = Array.isArray(downgradeSidecars?.entries) ? [...downgradeSidecars.entries] : [];
|
||
|
|
let sawRemote = false;
|
||
|
|
for (const runtime of runtimes) {
|
||
|
|
const remoteBundle = sidecarBundleFromPayload(
|
||
|
|
runtime.verifiedPayload ?? runtime.latestRemotePayload,
|
||
|
|
);
|
||
|
|
if (!remoteBundle) continue;
|
||
|
|
sawRemote = true;
|
||
|
|
let baseForProvider: NonNullable<SyncPayload['pluginSidecars']>['entries'] = [];
|
||
|
|
if (typeof this.loadConvergentProviderBaseline === 'function') {
|
||
|
|
try {
|
||
|
|
const baseline = await this.loadConvergentProviderBaseline(runtime.provider) as {
|
||
|
|
materializedPayload?: SyncPayload;
|
||
|
|
} | null;
|
||
|
|
const baseBundle = baseline?.materializedPayload?.pluginSidecars;
|
||
|
|
if (baseBundle && Array.isArray(baseBundle.entries)) {
|
||
|
|
baseForProvider = baseBundle.entries;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// First cycle or missing baseline is fine.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
entries = mergePluginSyncSidecarsThreeWay({
|
||
|
|
base: baseForProvider,
|
||
|
|
local: entries,
|
||
|
|
remote: Array.isArray(remoteBundle.entries) ? remoteBundle.entries : [],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (sawRemote || entries.length > 0) {
|
||
|
|
downgradeSidecars = { version: 1, entries };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const withLocalWrites = applyLegacySyncPayload(
|
||
|
|
replica.state,
|
||
|
|
materializedPayload(replica.state, now, downgradeSidecars),
|
||
|
|
stripConvergentSyncEnvelope(localPayload),
|
||
|
|
this.state.deviceId,
|
||
|
|
now,
|
||
|
|
);
|
||
|
|
const canonical = mergeStates(
|
||
|
|
withLocalWrites,
|
||
|
|
runtimes.flatMap((runtime) => runtime.verifiedState ? [runtime.verifiedState] : []),
|
||
|
|
);
|
||
|
|
const conflicts = currentConflicts(canonical);
|
||
|
|
if (conflicts.length > 0) {
|
||
|
|
const message = `Resolve ${conflicts.length} convergent conflict(s) before downgrading`;
|
||
|
|
for (const provider of providers) {
|
||
|
|
results.set(provider, failedResult(provider, message));
|
||
|
|
this.updateProviderStatus(provider, 'connected');
|
||
|
|
}
|
||
|
|
this.state.syncState = 'CONFLICT';
|
||
|
|
this.state.lastError = message;
|
||
|
|
this.notifyStateChange();
|
||
|
|
if (providers.length === 0) {
|
||
|
|
throw new Error(message);
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
const outgoing = materializedPayload(canonical, now, downgradeSidecars);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
let committed = false;
|
||
|
|
try {
|
||
|
|
await applyPayload(outgoing, async () => {
|
||
|
|
if (committed) return;
|
||
|
|
await persistReplica(this, canonical, now);
|
||
|
|
updateConflictState(this, canonical);
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.notifyStateChange();
|
||
|
|
committed = true;
|
||
|
|
});
|
||
|
|
if (!committed) {
|
||
|
|
throw new Error('Convergent downgrade apply completed without committing its replica');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
const message = errorMessage(error);
|
||
|
|
for (const provider of providers) this.updateProviderStatus(provider, 'error', message);
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = message;
|
||
|
|
this.notifyStateChange();
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
|
||
|
|
await Promise.all(runtimes.map(async (runtime) => {
|
||
|
|
const { provider, adapter, latestRemote: before } = runtime;
|
||
|
|
try {
|
||
|
|
const file = await EncryptionService.encryptPayload(
|
||
|
|
outgoing,
|
||
|
|
this.masterPassword,
|
||
|
|
this.state.deviceId,
|
||
|
|
this.state.deviceName,
|
||
|
|
packageJson.version,
|
||
|
|
before?.meta.version ?? this.state.localVersion,
|
||
|
|
);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
await adapter.upload(file, {
|
||
|
|
signal: this.activeSyncAbortSignal,
|
||
|
|
});
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
const verifiedFile = await adapter.download({
|
||
|
|
signal: this.activeSyncAbortSignal,
|
||
|
|
});
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
if (!verifiedFile) throw new Error('Provider returned no file after downgrade');
|
||
|
|
const verifiedPayload = await EncryptionService.decryptPayload(verifiedFile, this.masterPassword);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
if (validateConvergentSyncPayload(verifiedFile.meta, verifiedPayload) !== null) {
|
||
|
|
throw new Error('Provider still contains convergent metadata after downgrade');
|
||
|
|
}
|
||
|
|
if (!cloudSyncPayloadsEqual(outgoing, verifiedPayload)) {
|
||
|
|
throw new Error('Provider payload changed while verifying downgrade');
|
||
|
|
}
|
||
|
|
await this.saveConvergentProviderBaseline({
|
||
|
|
schemaVersion: 2,
|
||
|
|
provider,
|
||
|
|
remoteVersion: verifiedFile.meta.version,
|
||
|
|
remoteUpdatedAt: verifiedFile.meta.updatedAt,
|
||
|
|
remoteDeviceId: verifiedFile.meta.deviceId,
|
||
|
|
materializedPayload: verifiedPayload,
|
||
|
|
state: canonical,
|
||
|
|
});
|
||
|
|
// The convergent baseline above is removed after a successful
|
||
|
|
// downgrade. Seed the legacy three-way base and remote anchor from the
|
||
|
|
// exact verified v1 file before that cleanup, so the first legacy sync
|
||
|
|
// cannot compare a post-downgrade edit against pre-migration metadata.
|
||
|
|
await this.commitRemoteInspection(provider, verifiedFile, verifiedPayload);
|
||
|
|
assertSyncSecurityGeneration(this, syncSecurityGeneration);
|
||
|
|
results.set(provider, {
|
||
|
|
success: true,
|
||
|
|
provider,
|
||
|
|
action: 'upload',
|
||
|
|
version: verifiedFile.meta.version,
|
||
|
|
});
|
||
|
|
this.updateProviderStatus(provider, 'connected');
|
||
|
|
} catch (error) {
|
||
|
|
const message = updateProviderFailure(this, provider, error);
|
||
|
|
results.set(provider, failedResult(provider, message));
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
let failed = [...results.values()].find((result) => !result.success);
|
||
|
|
if (!failed) {
|
||
|
|
try {
|
||
|
|
this.completeConvergentSyncDowngrade(true);
|
||
|
|
} catch (error) {
|
||
|
|
const message = `Unable to finalize convergent downgrade: ${errorMessage(error)}`;
|
||
|
|
if (providers.length === 0) {
|
||
|
|
this.state.pendingLocalSync = true;
|
||
|
|
this.state.syncState = 'ERROR';
|
||
|
|
this.state.lastError = message;
|
||
|
|
this.notifyStateChange();
|
||
|
|
throw new Error(message);
|
||
|
|
}
|
||
|
|
for (const provider of providers) {
|
||
|
|
results.set(provider, failedResult(provider, message));
|
||
|
|
this.updateProviderStatus(provider, 'error', message);
|
||
|
|
}
|
||
|
|
failed = results.get(providers[0]!);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.state.pendingLocalSync = Boolean(failed);
|
||
|
|
this.state.syncState = failed ? 'ERROR' : 'IDLE';
|
||
|
|
this.state.lastError = failed?.error ?? null;
|
||
|
|
this.notifyStateChange();
|
||
|
|
return results;
|
||
|
|
});
|
||
|
|
}
|