[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
384
domain/convergentSync/migration.ts
Normal file
384
domain/convergentSync/migration.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user