Files
NetMesh/domain/convergentSync/payload.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

586 lines
21 KiB
TypeScript

import {
withHostsSanitizedForSync,
type CloudSyncPayloadEntityKey,
type SyncFileMeta,
type SyncPayload,
type SyncReliabilityMeta,
} from '../sync';
import { dotKey } from './clock';
import {
cloneJson,
isJsonValue,
jsonValuesEqual,
normalizeJsonValue,
} from './json';
import { selectRegisterWinner, isTombstoneCandidate } from './register';
import { createEmptyRecord, setOwnRecordValue } from './record';
import {
assertValidConvergentSyncState,
canonicalizeConvergentSyncState,
decodeSettingPath,
} from './serialization';
import {
applyConvergentMutations,
createConvergentSyncState,
materializeConvergentSyncState,
} from './state';
import type {
CollectionPosition,
ConvergentEnvelopeCandidate,
ConvergentEnvelopeCollectionState,
ConvergentEnvelopeEntityState,
ConvergentEnvelopeRegister,
ConvergentEnvelopeStateV2,
ConvergentEnvelopeStringCollectionState,
ConvergentEnvelopeStringEntryState,
ConvergentMutation,
ConvergentSyncEnvelopeV2,
ConvergentSyncStateV2,
JsonObject,
JsonValue,
MultiValueRegister,
RegisterCandidate,
} from './types';
export const CONVERGENT_ENTITY_COLLECTIONS = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'notes',
'portForwardingRules',
'groupConfigs',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
export const CONVERGENT_STRING_COLLECTIONS = [
'customGroups',
'snippetPackages',
'noteGroups',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
type ConvergentEntityCollection = typeof CONVERGENT_ENTITY_COLLECTIONS[number];
type ConvergentStringCollection = typeof CONVERGENT_STRING_COLLECTIONS[number];
const ENTITY_COLLECTION_SET = new Set<string>(CONVERGENT_ENTITY_COLLECTIONS);
const STRING_COLLECTION_SET = new Set<string>(CONVERGENT_STRING_COLLECTIONS);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toJsonValue(value: unknown, label: string): JsonValue {
try {
return normalizeJsonValue(value);
} catch {
throw new Error(`${label} contains a value that cannot be represented as JSON`);
}
}
function entityId(collection: ConvergentEntityCollection, value: Record<string, unknown>): string {
const raw = collection === 'groupConfigs' ? value.path : value.id;
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error(`${collection} contains an entity without a stable identifier`);
}
return raw;
}
function entityJson(
collection: ConvergentEntityCollection,
value: Record<string, unknown>,
): JsonObject {
const id = entityId(collection, value);
const json = toJsonValue(value, `${collection}/${id}`);
if (!isRecord(json)) throw new Error(`${collection}/${id} must be a JSON object`);
return {
...json,
id,
} as JsonObject;
}
function payloadEntityValues(
payload: SyncPayload,
collection: ConvergentEntityCollection,
): Record<string, unknown>[] {
const values = payload[collection];
return Array.isArray(values) ? values as unknown as Record<string, unknown>[] : [];
}
function payloadStringValues(
payload: SyncPayload,
collection: ConvergentStringCollection,
): string[] {
const values = payload[collection];
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: [];
}
function appendSettingMutations(
value: unknown,
path: string[],
mutations: ConvergentMutation[],
): void {
if (isRecord(value) && Object.keys(value).length > 0) {
for (const key of Object.keys(value).sort()) {
appendSettingMutations(value[key], [...path, key], mutations);
}
return;
}
if (path.length === 0 || value === undefined) return;
mutations.push({
kind: 'setting-set',
path,
value: toJsonValue(value, `settings.${path.join('.')}`),
});
}
export function syncPayloadToConvergentMutations(payload: SyncPayload): ConvergentMutation[] {
const sanitized = withHostsSanitizedForSync(payload);
const mutations: ConvergentMutation[] = [];
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
payloadEntityValues(sanitized, collection).forEach((value, position) => {
const id = entityId(collection, value);
mutations.push({
kind: 'entity-upsert',
collection,
entityId: id,
value: entityJson(collection, value),
position,
});
});
}
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
payloadStringValues(sanitized, collection).forEach((value, position) => {
mutations.push({ kind: 'string-entry-add', collection, value, position });
});
}
appendSettingMutations(sanitized.settings, [], mutations);
return mutations;
}
export function createConvergentSyncStateFromPayload(
payload: SyncPayload,
deviceId: string,
now: number,
): ConvergentSyncStateV2 {
return applyConvergentMutations(
createConvergentSyncState(),
deviceId,
syncPayloadToConvergentMutations(payload),
now,
);
}
function requireKnownCollections(state: ConvergentSyncStateV2): void {
for (const collection of Object.keys(state.collections)) {
if (!ENTITY_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent entity collection: ${collection}`);
}
}
for (const collection of Object.keys(state.stringCollections)) {
if (!STRING_COLLECTION_SET.has(collection)) {
throw new Error(`Unsupported convergent string collection: ${collection}`);
}
}
}
function collectionValues(
collections: Record<string, JsonObject[]>,
collection: ConvergentEntityCollection,
): JsonObject[] {
return collections[collection] ?? [];
}
function typedCollection<T>(
collections: Record<string, JsonObject[]>,
collection: Exclude<ConvergentEntityCollection, 'groupConfigs'>,
): T[] {
return collectionValues(collections, collection) as unknown as T[];
}
export function materializeSyncPayloadFromConvergentState(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
/** Opaque plugin sidecars travel with the encrypted blob outside CRDT fields. */
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
requireKnownCollections(state);
const materialized = materializeConvergentSyncState(state);
const groupConfigs = collectionValues(materialized.collections, 'groupConfigs').map((value) => {
const { id: _id, ...groupConfig } = value;
return groupConfig as unknown as import('../models').GroupConfig;
});
const settings = Object.keys(materialized.settings).length > 0
? materialized.settings as unknown as NonNullable<SyncPayload['settings']>
: undefined;
return {
hosts: typedCollection<import('../models').Host>(materialized.collections, 'hosts'),
keys: typedCollection<import('../models').SSHKey>(materialized.collections, 'keys'),
identities: typedCollection<import('../models').Identity>(materialized.collections, 'identities'),
proxyProfiles: typedCollection<import('../models').ProxyProfile>(materialized.collections, 'proxyProfiles'),
snippets: typedCollection<import('../models').Snippet>(materialized.collections, 'snippets'),
customGroups: materialized.stringCollections.customGroups ?? [],
snippetPackages: materialized.stringCollections.snippetPackages ?? [],
notes: typedCollection<import('../models').VaultNote>(materialized.collections, 'notes'),
noteGroups: materialized.stringCollections.noteGroups ?? [],
portForwardingRules: typedCollection<import('../models').PortForwardingRule>(materialized.collections, 'portForwardingRules'),
groupConfigs,
settings,
syncedAt: options.syncedAt,
...(options.syncMeta ? { syncMeta: options.syncMeta } : {}),
// Preserve explicit empty bundles so lifecycle materializations (conflict
// resolve / downgrade) can clear or re-upload sidecars rather than omit
// the field and look like a legacy payload.
...(options.pluginSidecars && Array.isArray(options.pluginSidecars.entries)
? {
pluginSidecars: {
version: 1 as const,
entries: options.pluginSidecars.entries,
},
}
: {}),
};
}
function materializedEntity(
payload: SyncPayload,
collection: string,
id: string,
): Record<string, unknown> | undefined {
if (!ENTITY_COLLECTION_SET.has(collection)) return undefined;
const values = payloadEntityValues(payload, collection as ConvergentEntityCollection);
return values.find((value) => entityId(collection as ConvergentEntityCollection, value) === id);
}
function nestedSetting(payload: SyncPayload, path: string[]): unknown {
let value: unknown = payload.settings;
for (const segment of path) {
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) return undefined;
value = value[segment];
}
return value;
}
function stableUnknown(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableUnknown);
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, stableUnknown(value[key])]),
);
}
return value;
}
function materializedCloudFingerprint(payload: SyncPayload): string {
return JSON.stringify(stableUnknown({
...Object.fromEntries(
CONVERGENT_ENTITY_COLLECTIONS.map((collection) => [
collection,
payloadEntityValues(payload, collection),
]),
),
...Object.fromEntries(
CONVERGENT_STRING_COLLECTIONS.map((collection) => [
collection,
payloadStringValues(payload, collection),
]),
),
settings: payload.settings ?? {},
}));
}
function assertMaterializedPayloadMatchesState(
state: ConvergentSyncStateV2,
payload: SyncPayload,
): void {
const expected = materializeSyncPayloadFromConvergentState(state, { syncedAt: 0 });
if (materializedCloudFingerprint(expected) !== materializedCloudFingerprint(payload)) {
throw new Error('Convergent envelope does not match its materialized v1 snapshot');
}
}
function compactRegister<T extends JsonValue>(
register: MultiValueRegister<T>,
materializedValue?: unknown,
allowMaterializedValue = false,
): ConvergentEnvelopeRegister<T> {
const winner = selectRegisterWinner(register);
return {
candidates: register.candidates.map((candidate): ConvergentEnvelopeCandidate<T> => {
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (isTombstoneCandidate(candidate)) return { ...base, tombstone: true };
if (
allowMaterializedValue
&& winner
&& dotKey(candidate.dot) === dotKey(winner.dot)
&& isJsonValue(materializedValue)
&& jsonValuesEqual(candidate.value, materializedValue)
) {
return { ...base, materialized: true };
}
return { ...base, value: cloneJson(candidate.value) };
}),
};
}
export function createConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
materializedPayload: SyncPayload,
): ConvergentSyncEnvelopeV2 {
const canonical = canonicalizeConvergentSyncState(state);
requireKnownCollections(canonical);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
const collections = createEmptyRecord<ConvergentEnvelopeCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.collections)) {
const entities = createEmptyRecord<ConvergentEnvelopeEntityState>();
for (const [id, entity] of Object.entries(collection.entities)) {
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, compactRegister(register, materialized?.[field], true));
}
setOwnRecordValue(entities, id, {
presence: compactRegister(entity.presence),
...(entity.position ? { position: compactRegister(entity.position) } : {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<ConvergentEnvelopeRegister>();
for (const [encodedPath, register] of Object.entries(canonical.settings)) {
setOwnRecordValue(settings, encodedPath, compactRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(encodedPath)),
true,
));
}
const stringCollections = createEmptyRecord<ConvergentEnvelopeStringCollectionState>();
for (const [collectionName, collection] of Object.entries(canonical.stringCollections)) {
const entries = createEmptyRecord<ConvergentEnvelopeStringEntryState>();
for (const [value, entry] of Object.entries(collection.entries)) {
setOwnRecordValue(entries, value, {
presence: compactRegister(entry.presence),
...(entry.position ? { position: compactRegister(entry.position) } : {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
return {
schemaVersion: 2,
encoding: 'materialized-winner-v1',
state: {
vector: Object.fromEntries(Object.entries(canonical.vector)),
dotOrigins: Object.fromEntries(
Object.entries(canonical.dotOrigins).map(([deviceId, origins]) => [deviceId, { ...origins }]),
),
hlc: { ...canonical.hlc },
collections,
settings,
stringCollections,
},
};
}
function hydrateRegister<T extends JsonValue>(
register: ConvergentEnvelopeRegister<T>,
materializedValue: unknown,
label: string,
): MultiValueRegister<T> {
if (!register || !Array.isArray(register.candidates) || register.candidates.length === 0) {
throw new Error(`${label} has no candidates`);
}
return {
candidates: register.candidates.map((candidate, index): RegisterCandidate<T> => {
const candidateLabel = `${label}.candidates[${index}]`;
const base = {
dot: { ...candidate.dot },
context: candidate.context.map((dot) => ({ ...dot })),
hlc: { ...candidate.hlc },
};
if (
candidate.tombstone !== undefined
&& candidate.tombstone !== true
&& candidate.tombstone !== false
) {
throw new Error(`${candidateLabel} has an invalid tombstone marker`);
}
if (
'materialized' in candidate
&& candidate.materialized !== undefined
&& candidate.materialized !== true
) {
throw new Error(`${candidateLabel} has an invalid materialized marker`);
}
if (candidate.tombstone === true) {
if ('materialized' in candidate || 'value' in candidate) {
throw new Error(`${candidateLabel} tombstone contains a value marker`);
}
return { ...base, tombstone: true };
}
if ('materialized' in candidate && candidate.materialized === true) {
if ('value' in candidate || !isJsonValue(materializedValue)) {
throw new Error(`${candidateLabel} cannot reconstruct its materialized value`);
}
return { ...base, value: cloneJson(materializedValue) as T };
}
if (!('value' in candidate) || !isJsonValue(candidate.value)) {
throw new Error(`${candidateLabel} is missing a JSON value`);
}
return { ...base, value: cloneJson(candidate.value) as T };
}),
};
}
function envelopeState(value: unknown): ConvergentEnvelopeStateV2 {
if (!isRecord(value)) throw new Error('Convergent sync envelope state is invalid');
return value as unknown as ConvergentEnvelopeStateV2;
}
export function hydrateConvergentSyncEnvelope(
envelope: ConvergentSyncEnvelopeV2,
materializedPayload: SyncPayload,
): ConvergentSyncStateV2 {
if (
!envelope
|| envelope.schemaVersion !== 2
|| envelope.encoding !== 'materialized-winner-v1'
) {
throw new Error('Unsupported convergent sync envelope');
}
const encoded = envelopeState(envelope.state);
const collections = createEmptyRecord<ConvergentSyncStateV2['collections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.collections ?? {})) {
if (!ENTITY_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entities)) {
throw new Error(`Unsupported or invalid convergent collection: ${collectionName}`);
}
const entities = createEmptyRecord<ConvergentSyncStateV2['collections'][string]['entities'][string]>();
for (const [id, entity] of Object.entries(collection.entities)) {
if (!isRecord(entity) || !isRecord(entity.fields)) {
throw new Error(`Invalid convergent entity: ${collectionName}/${id}`);
}
const materialized = materializedEntity(materializedPayload, collectionName, id);
const fields = createEmptyRecord<MultiValueRegister>();
for (const [field, register] of Object.entries(entity.fields)) {
setOwnRecordValue(fields, field, hydrateRegister(
register,
materialized?.[field],
`${collectionName}/${id}/${field}`,
));
}
setOwnRecordValue(entities, id, {
presence: hydrateRegister(entity.presence, true, `${collectionName}/${id}/presence`),
...(entity.position
? { position: hydrateRegister<CollectionPosition>(entity.position, undefined, `${collectionName}/${id}/position`) }
: {}),
fields,
});
}
setOwnRecordValue(collections, collectionName, { entities });
}
const settings = createEmptyRecord<MultiValueRegister>();
for (const [path, register] of Object.entries(encoded.settings ?? {})) {
setOwnRecordValue(settings, path, hydrateRegister(
register,
nestedSetting(materializedPayload, decodeSettingPath(path)),
`settings/${path}`,
));
}
const stringCollections = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]>();
for (const [collectionName, collection] of Object.entries(encoded.stringCollections ?? {})) {
if (!STRING_COLLECTION_SET.has(collectionName) || !isRecord(collection?.entries)) {
throw new Error(`Unsupported or invalid convergent string collection: ${collectionName}`);
}
const entries = createEmptyRecord<ConvergentSyncStateV2['stringCollections'][string]['entries'][string]>();
for (const [value, entry] of Object.entries(collection.entries)) {
if (!isRecord(entry)) throw new Error(`Invalid convergent string entry: ${collectionName}/${value}`);
setOwnRecordValue(entries, value, {
presence: hydrateRegister(entry.presence, true, `${collectionName}/${value}/presence`),
...(entry.position
? { position: hydrateRegister<CollectionPosition>(entry.position, undefined, `${collectionName}/${value}/position`) }
: {}),
});
}
setOwnRecordValue(stringCollections, collectionName, { entries });
}
const state: ConvergentSyncStateV2 = {
schemaVersion: 2,
vector: encoded.vector,
dotOrigins: encoded.dotOrigins,
hlc: encoded.hlc,
collections,
settings,
stringCollections,
};
assertValidConvergentSyncState(state);
const canonical = canonicalizeConvergentSyncState(state);
assertMaterializedPayloadMatchesState(canonical, materializedPayload);
return canonical;
}
export function withConvergentSyncEnvelope(
state: ConvergentSyncStateV2,
options: {
syncedAt: number;
syncMeta?: SyncReliabilityMeta;
pluginSidecars?: SyncPayload['pluginSidecars'];
},
): SyncPayload {
const payload = materializeSyncPayloadFromConvergentState(state, options);
return {
...payload,
convergentSync: createConvergentSyncEnvelope(state, payload),
};
}
export function validateConvergentSyncPayload(
meta: Pick<SyncFileMeta, 'syncSchemaVersion'>,
payload: SyncPayload,
): ConvergentSyncStateV2 | null {
const schemaVersion = (meta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (schemaVersion === undefined) {
if (payload.convergentSync !== undefined) {
throw new Error('Convergent sync envelope is present without schema metadata');
}
return null;
}
if (schemaVersion !== 2) {
throw new Error(`Unsupported sync schema version: ${String(schemaVersion)}`);
}
if (!payload.convergentSync) {
throw new Error('Sync schema v2 payload is missing its convergent envelope');
}
return hydrateConvergentSyncEnvelope(payload.convergentSync, payload);
}
/** Prevent the legacy snapshot writer from silently erasing v2/future metadata. */
export function assertConvergentSyncWriteCompatible(
remoteMeta: Pick<SyncFileMeta, 'syncSchemaVersion'> | null | undefined,
outgoingPayload: SyncPayload,
): void {
if (!remoteMeta) return;
const remoteSchema = (remoteMeta as { syncSchemaVersion?: unknown }).syncSchemaVersion;
if (remoteSchema === undefined) return;
if (remoteSchema !== 2) {
throw new Error(`Cannot overwrite unsupported sync schema version: ${String(remoteSchema)}`);
}
if (!outgoingPayload.convergentSync) {
throw new Error(
'Cloud data uses convergent sync v2. Enable or migrate convergent sync before uploading.',
);
}
}
export function stripConvergentSyncEnvelope(payload: SyncPayload): SyncPayload {
const { convergentSync: _convergentSync, ...legacyPayload } = payload;
return legacyPayload;
}