385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
|
|
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'] | null | undefined>
|
||
|
|
): SyncPayload['pluginSidecars'] | undefined {
|
||
|
|
let entries: NonNullable<SyncPayload['pluginSidecars']>['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<string, unknown>);
|
||
|
|
if (entries.length === 0) return root ? 0 : 1;
|
||
|
|
return entries.reduce<number>(
|
||
|
|
(total, child) => total + countSettingsLeaves(child, false),
|
||
|
|
0,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function entityCount(payload: SyncPayload, key: string): number {
|
||
|
|
const value = (payload as unknown as Record<string, unknown>)[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<ConvergentMigrationProviderInput, { status: 'ready' }>[] = [];
|
||
|
|
const v2Inputs: Array<Extract<ConvergentMigrationProviderInput, { status: 'ready' }> & { 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,
|
||
|
|
};
|
||
|
|
}
|