import { CLOUD_SYNC_PAYLOAD_ENTITY_KEYS, hasSyncPayloadEntityData, type CloudProvider, type ConvergentMigrationPreview, type ConvergentProviderMigrationStatus, type SyncFileMeta, type SyncPayload, } from '../sync'; import { detectSuspiciousShrink } from '../syncGuards'; import { mergeSyncPayloads } from '../syncMerge'; import { summarizeSyncChanges } from '../syncReliability'; import { mergeConvergentSyncStates, materializeConvergentSyncState } from './state'; import type { ConvergentSyncStateV2 } from './types'; import { cloudSyncPayloadsEqual, applyLegacySyncPayload, inheritOmittedLegacySyncFields, } from './legacy'; import { CONVERGENT_ENTITY_COLLECTIONS, CONVERGENT_STRING_COLLECTIONS, createConvergentSyncStateFromPayload, hydrateConvergentSyncEnvelope, materializeSyncPayloadFromConvergentState, withConvergentSyncEnvelope, } from './payload'; import { mergePluginSyncSidecars, mergePluginSyncSidecarsThreeWay, } from '../pluginSyncSidecar'; /** LWW-union plugin sidecars from every migration input that carries them. */ function mergeMigrationSidecars( ...bundles: Array ): SyncPayload['pluginSidecars'] | undefined { let entries: NonNullable['entries'] = []; let sawAny = false; for (const bundle of bundles) { if (!bundle || !Array.isArray(bundle.entries)) continue; sawAny = true; entries = mergePluginSyncSidecars({ local: entries, remote: bundle }); } return sawAny ? { version: 1, entries } : undefined; } /** * Three-way merge local sidecars against each remote source using that * source's trusted baseline (falls back to local baseline). Preserves * explicit local deletions instead of resurrecting them via LWW union. */ function mergeMigrationSidecarsWithBaselines(options: { local: SyncPayload['pluginSidecars'] | null | undefined; localBaseline: SyncPayload['pluginSidecars'] | null | undefined; sources: Array<{ remote: SyncPayload['pluginSidecars'] | null | undefined; baseline: SyncPayload['pluginSidecars'] | null | undefined; }>; }): SyncPayload['pluginSidecars'] | undefined { let entries = Array.isArray(options.local?.entries) ? [...options.local.entries] : []; const localBase = Array.isArray(options.localBaseline?.entries) ? options.localBaseline.entries : []; let sawAny = Array.isArray(options.local?.entries); for (const source of options.sources) { if (!source.remote || !Array.isArray(source.remote.entries)) continue; sawAny = true; const base = Array.isArray(source.baseline?.entries) ? source.baseline.entries : localBase; entries = mergePluginSyncSidecarsThreeWay({ base, local: entries, remote: source.remote.entries, }); } if (!sawAny) return undefined; return { version: 1, entries }; } /* * A local snapshot with no cloud entities and no trusted base is a fresh * device, not an untrusted deletion. Settings are intentionally ignored here * because first-launch defaults must not prevent adoption of an existing v2 * vault. Once a trusted base exists, an empty snapshot remains a real deletion. */ function shouldIncludeLegacyLocalSource( payload: SyncPayload, trustedBaseline: SyncPayload | null, ): boolean { return trustedBaseline !== null || hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS); } export type ConvergentMigrationProviderInput = | { provider: CloudProvider; status: 'empty' } | { provider: CloudProvider; status: 'unavailable'; message: string } | { provider: CloudProvider; status: 'ready'; meta: SyncFileMeta; payload: SyncPayload; trustedBaseline: SyncPayload | null; }; export interface ConvergentMigrationPlan { preview: ConvergentMigrationPreview; state: ConvergentSyncStateV2 | null; payload: SyncPayload | null; } function runtimeSchema(meta: SyncFileMeta): 1 | 2 | 'future' | 'invalid' { const value = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion; if (value === undefined) return 1; if (value === 2) return 2; if (typeof value === 'number' && Number.isInteger(value) && value > 2) return 'future'; return 'invalid'; } function countSettingsLeaves(value: unknown, root = true): number { if (!value || typeof value !== 'object' || Array.isArray(value)) return value === undefined ? 0 : 1; const entries = Object.values(value as Record); if (entries.length === 0) return root ? 0 : 1; return entries.reduce( (total, child) => total + countSettingsLeaves(child, false), 0, ); } function entityCount(payload: SyncPayload, key: string): number { const value = (payload as unknown as Record)[key]; return Array.isArray(value) ? value.length : 0; } function statusFor( input: ConvergentMigrationProviderInput, schemaVersion: ConvergentProviderMigrationStatus['schemaVersion'], status: ConvergentProviderMigrationStatus['status'], message?: string, ): ConvergentProviderMigrationStatus { return { provider: input.provider, status, schemaVersion, entityCount: input.status === 'ready' ? [...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS] .reduce((total, key) => total + entityCount(input.payload, key), 0) : 0, hasTrustedBaseline: input.status === 'ready' && input.trustedBaseline !== null, ...(message ? { message } : {}), }; } export function planConvergentSyncMigration(options: { localPayload: SyncPayload; localTrustedBaseline: SyncPayload | null; providers: ConvergentMigrationProviderInput[]; deviceId: string; now: number; }): ConvergentMigrationPlan { const providers = [...options.providers].sort((left, right) => left.provider.localeCompare(right.provider)); const blockedReasons: string[] = []; const providerStatuses: ConvergentProviderMigrationStatus[] = []; const shrinkFindings: ConvergentMigrationPreview['shrinkFindings'] = []; const v1Inputs: Extract[] = []; const v2Inputs: Array & { state: ConvergentSyncStateV2 }> = []; for (const input of providers) { if (input.status === 'empty') { providerStatuses.push(statusFor(input, 1, 'empty')); continue; } if (input.status === 'unavailable') { blockedReasons.push(`${input.provider}: ${input.message}`); providerStatuses.push(statusFor(input, 'invalid', 'unavailable', input.message)); continue; } const schema = runtimeSchema(input.meta); if (schema === 'future' || schema === 'invalid') { const message = schema === 'future' ? 'Provider contains a newer sync schema' : 'Provider contains invalid sync schema metadata'; blockedReasons.push(`${input.provider}: ${message}`); providerStatuses.push(statusFor(input, schema, 'blocked', message)); continue; } if (schema === 1) { if (input.payload.convergentSync) { const message = 'Provider envelope does not match its plaintext schema metadata'; blockedReasons.push(`${input.provider}: ${message}`); providerStatuses.push(statusFor(input, 'invalid', 'blocked', message)); } else { v1Inputs.push(input); providerStatuses.push(statusFor(input, 1, 'ready')); } continue; } try { if (!input.payload.convergentSync) throw new Error('missing convergent envelope'); const state = hydrateConvergentSyncEnvelope(input.payload.convergentSync, input.payload); v2Inputs.push({ ...input, state }); providerStatuses.push(statusFor(input, 2, 'ready')); } catch (error) { const message = `Damaged convergent envelope: ${error instanceof Error ? error.message : String(error)}`; blockedReasons.push(`${input.provider}: ${message}`); providerStatuses.push(statusFor(input, 'invalid', 'blocked', message)); } } let state: ConvergentSyncStateV2 | null = null; let materialized: SyncPayload | null = null; if (blockedReasons.length === 0 && v2Inputs.length === 0) { const includeLocalSource = shouldIncludeLegacyLocalSource( options.localPayload, options.localTrustedBaseline, ); const seedFromProvider = !includeLocalSource && v1Inputs.length > 0; let merged = seedFromProvider ? v1Inputs[0].payload : options.localPayload; if (seedFromProvider) { const seed = v1Inputs[0]; const shrink = detectSuspiciousShrink( seed.payload, seed.trustedBaseline, seed.payload, ); if (shrink.suspicious) { shrinkFindings.push({ provider: seed.provider, finding: shrink }); blockedReasons.push(`${seed.provider}: legacy migration would remove too many entities`); } } const remainingInputs = seedFromProvider ? v1Inputs.slice(1) : v1Inputs; for (const input of remainingInputs) { if (!input.trustedBaseline) { if (!cloudSyncPayloadsEqual(merged, input.payload)) { blockedReasons.push(`${input.provider}: no trusted legacy baseline is available`); } continue; } const result = mergeSyncPayloads(input.trustedBaseline, merged, input.payload); const changeSummary = summarizeSyncChanges( input.trustedBaseline, merged, input.payload, ); if (result.hadConflicts || changeSummary.hasConflicts) { blockedReasons.push(`${input.provider}: legacy smart merge has unresolved conflicts`); } const shrink = detectSuspiciousShrink(result.payload, input.trustedBaseline, input.payload); if (shrink.suspicious) { shrinkFindings.push({ provider: input.provider, finding: shrink }); blockedReasons.push(`${input.provider}: legacy migration would remove too many entities`); } merged = result.payload; } if (blockedReasons.length === 0) { state = createConvergentSyncStateFromPayload(merged, options.deviceId, options.now); // Prefer the three-way merge result already on `merged` (preserves // explicit sidecar deletions). Do not re-LWW raw provider bundles — // that would resurrect entries three-way merge correctly removed. const migrationSidecars = Object.prototype.hasOwnProperty.call(merged, 'pluginSidecars') ? merged.pluginSidecars : mergeMigrationSidecars( options.localPayload.pluginSidecars, ...v1Inputs.map((input) => input.payload.pluginSidecars), ); materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now, syncMeta: merged.syncMeta, ...(migrationSidecars ? { pluginSidecars: migrationSidecars } : {}), }); } } else if (blockedReasons.length === 0) { state = v2Inputs.map((input) => input.state).reduce(mergeConvergentSyncStates); // Three-way per provider so local resets are not resurrected from a // still-stale remote entry during convergent enablement. const joinedSidecars = mergeMigrationSidecarsWithBaselines({ local: options.localPayload.pluginSidecars, localBaseline: options.localTrustedBaseline?.pluginSidecars, sources: [ ...v2Inputs.map((input) => ({ remote: input.payload.pluginSidecars, baseline: input.trustedBaseline?.pluginSidecars, })), ...v1Inputs.map((input) => ({ remote: input.payload.pluginSidecars, baseline: input.trustedBaseline?.pluginSidecars, })), ], }); const joinedPayload = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now, ...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}), }); const legacySources: Array<{ id: string; payload: SyncPayload; baseline: SyncPayload | null; now: number; provider?: CloudProvider; }> = [ ...(shouldIncludeLegacyLocalSource( options.localPayload, options.localTrustedBaseline, ) ? [{ id: `legacy-local:${options.deviceId}`, payload: options.localPayload, baseline: options.localTrustedBaseline, now: options.now, }] : []), ...v1Inputs.map((input) => ({ id: `legacy-provider:${input.provider}:${input.meta.deviceId}`, payload: input.payload, baseline: input.trustedBaseline, now: input.meta.updatedAt, provider: input.provider, })), ]; const branches: ConvergentSyncStateV2[] = []; for (const source of legacySources) { if (cloudSyncPayloadsEqual(source.payload, joinedPayload)) continue; if (!source.baseline) { blockedReasons.push(`${source.id}: no trusted legacy baseline is available`); continue; } const shrink = detectSuspiciousShrink( inheritOmittedLegacySyncFields(source.baseline, source.payload), source.baseline, ); if (shrink.suspicious) { if (source.provider) { shrinkFindings.push({ provider: source.provider, finding: shrink }); } blockedReasons.push(`${source.id}: legacy migration would remove too many entities`); continue; } branches.push(applyLegacySyncPayload(state, source.baseline, source.payload, source.id, source.now)); } if (blockedReasons.length === 0) { state = branches.reduce(mergeConvergentSyncStates, state); // joinedSidecars already unions local + all provider inputs. Re-LWW-ing // raw sources again cannot add unique entries and can confuse future // three-way paths that expect the joined set to be final. materialized = materializeSyncPayloadFromConvergentState(state, { syncedAt: options.now, ...(joinedSidecars ? { pluginSidecars: joinedSidecars } : {}), }); } } const conflicts = state ? materializeConvergentSyncState(state).conflicts : []; if (conflicts.length > 0) blockedReasons.push('The convergent state contains unresolved field conflicts'); const canInitialize = blockedReasons.length === 0 && state !== null && materialized !== null; const payload = canInitialize && state ? withConvergentSyncEnvelope(state, { syncedAt: options.now, syncMeta: materialized?.syncMeta, ...(materialized?.pluginSidecars ? { pluginSidecars: materialized.pluginSidecars } : {}), }) : null; const previewPayload = materialized ?? options.localPayload; const entityCounts = Object.fromEntries( [...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS] .map((key) => [key, entityCount(previewPayload, key)]), ) as ConvergentMigrationPreview['entityCounts']; return { preview: { schemaVersion: 2, canInitialize, entityCounts, settingsLeafCount: countSettingsLeaves(previewPayload.settings), conflictCount: conflicts.length, conflicts, shrinkFindings, providers: providerStatuses, oldClientCompatibility: 'materialized-v1-snapshot', blockedReasons, }, state: canInitialize ? state : null, payload, }; }