[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:
231
domain/convergentSync/legacy.ts
Normal file
231
domain/convergentSync/legacy.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { withHostsSanitizedForSync, type SyncPayload } from '../sync';
|
||||
import { normalizeJsonValue } from './json';
|
||||
import { encodeSettingPath } from './serialization';
|
||||
import { applyConvergentMutations } from './state';
|
||||
import type {
|
||||
ConvergentMutation,
|
||||
ConvergentSyncStateV2,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
import {
|
||||
CONVERGENT_ENTITY_COLLECTIONS,
|
||||
CONVERGENT_STRING_COLLECTIONS,
|
||||
} from './payload';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stableValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fingerprint(value: unknown): string {
|
||||
return JSON.stringify(stableValue(value));
|
||||
}
|
||||
|
||||
function hasDefinedOwnProperty(payload: SyncPayload, property: string): boolean {
|
||||
const record = payload as unknown as Record<string, unknown>;
|
||||
return Object.prototype.hasOwnProperty.call(record, property)
|
||||
&& record[property] !== undefined;
|
||||
}
|
||||
|
||||
/** Preserve fields that an older client did not provide for safety checks. */
|
||||
export function inheritOmittedLegacySyncFields(
|
||||
baseline: SyncPayload,
|
||||
legacy: SyncPayload,
|
||||
): SyncPayload {
|
||||
const result = { ...legacy } as SyncPayload;
|
||||
const resultRecord = result as unknown as Record<string, unknown>;
|
||||
const baselineRecord = baseline as unknown as Record<string, unknown>;
|
||||
for (const property of [
|
||||
...CONVERGENT_ENTITY_COLLECTIONS,
|
||||
...CONVERGENT_STRING_COLLECTIONS,
|
||||
'settings',
|
||||
]) {
|
||||
if (!hasDefinedOwnProperty(legacy, property)) {
|
||||
resultRecord[property] = baselineRecord[property];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizedEntityValue(
|
||||
collection: string,
|
||||
id: string,
|
||||
value: Record<string, unknown>,
|
||||
): Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'] {
|
||||
try {
|
||||
const normalized = normalizeJsonValue({ ...value, id });
|
||||
if (!isRecord(normalized)) throw new TypeError('Entity is not an object');
|
||||
return normalized as Extract<ConvergentMutation, { kind: 'entity-upsert' }>['value'];
|
||||
} catch {
|
||||
throw new Error(`${collection}/${id} contains a value that cannot be represented as JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function entityId(collection: string, value: Record<string, unknown>): string | undefined {
|
||||
const id = collection === 'groupConfigs' ? value.path : value.id;
|
||||
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
function entityMap(payload: SyncPayload, collection: string): Map<string, Record<string, unknown>> {
|
||||
const values = (payload as unknown as Record<string, unknown>)[collection];
|
||||
const result = new Map<string, Record<string, unknown>>();
|
||||
if (!Array.isArray(values)) return result;
|
||||
for (const value of values) {
|
||||
if (!isRecord(value)) continue;
|
||||
const id = entityId(collection, value);
|
||||
if (id) result.set(id, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringSet(payload: SyncPayload, collection: string): Set<string> {
|
||||
const values = (payload as unknown as Record<string, unknown>)[collection];
|
||||
return new Set(Array.isArray(values) ? values.filter((value): value is string => typeof value === 'string') : []);
|
||||
}
|
||||
|
||||
function positionMap(values: Iterable<string>): Map<string, number> {
|
||||
const positions = new Map<string, number>();
|
||||
let position = 0;
|
||||
for (const value of values) {
|
||||
positions.set(value, position);
|
||||
position += 1;
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
function flattenSettings(
|
||||
value: unknown,
|
||||
path: string[] = [],
|
||||
output: Map<string, { path: string[]; value: JsonValue }> = new Map(),
|
||||
): Map<string, { path: string[]; value: JsonValue }> {
|
||||
if (isRecord(value) && Object.keys(value).length > 0) {
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
flattenSettings(value[key], [...path, key], output);
|
||||
}
|
||||
} else if (path.length > 0 && value !== undefined) {
|
||||
output.set(encodeSettingPath(path), { path, value: value as JsonValue });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Compare only cloud materialized data; timestamps and reliability metadata are transport details. */
|
||||
export function cloudSyncPayloadsEqual(left: SyncPayload, right: SyncPayload): boolean {
|
||||
const project = (payload: SyncPayload) => {
|
||||
const sanitized = withHostsSanitizedForSync(payload);
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
[...CONVERGENT_ENTITY_COLLECTIONS, ...CONVERGENT_STRING_COLLECTIONS]
|
||||
.map((key) => [key, (sanitized as unknown as Record<string, unknown>)[key] ?? []]),
|
||||
),
|
||||
settings: sanitized.settings ?? {},
|
||||
// Plugin sidecars are host-owned opaque data on the encrypted blob and
|
||||
// must participate in migration freshness / equality checks.
|
||||
pluginSidecars: sanitized.pluginSidecars ?? { version: 1, entries: [] },
|
||||
};
|
||||
};
|
||||
return fingerprint(project(left)) === fingerprint(project(right));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a trusted v1 baseline diff into deterministic CRDT writes. A missing
|
||||
* or undefined optional top-level collection is treated as "unsupported by
|
||||
* that client", while an explicitly present empty collection is a real
|
||||
* deletion.
|
||||
*/
|
||||
export function diffLegacySyncPayload(
|
||||
baseline: SyncPayload,
|
||||
legacy: SyncPayload,
|
||||
): ConvergentMutation[] {
|
||||
const mutations: ConvergentMutation[] = [];
|
||||
const sanitizedBaseline = withHostsSanitizedForSync(baseline);
|
||||
const sanitizedLegacy = withHostsSanitizedForSync(legacy);
|
||||
for (const collection of CONVERGENT_ENTITY_COLLECTIONS) {
|
||||
if (!hasDefinedOwnProperty(legacy, collection)) continue;
|
||||
const before = entityMap(sanitizedBaseline, collection);
|
||||
const after = entityMap(sanitizedLegacy, collection);
|
||||
const beforePositions = positionMap(before.keys());
|
||||
const afterPositions = positionMap(after.keys());
|
||||
const ids = new Set([...before.keys(), ...after.keys()]);
|
||||
for (const id of [...ids].sort()) {
|
||||
const previous = before.get(id);
|
||||
const next = after.get(id);
|
||||
if (previous && !next) {
|
||||
mutations.push({ kind: 'entity-delete', collection, entityId: id });
|
||||
} else if (
|
||||
next
|
||||
&& (
|
||||
!previous
|
||||
|| fingerprint(previous) !== fingerprint(next)
|
||||
|| beforePositions.get(id) !== afterPositions.get(id)
|
||||
)
|
||||
) {
|
||||
mutations.push({
|
||||
kind: 'entity-upsert',
|
||||
collection,
|
||||
entityId: id,
|
||||
value: normalizedEntityValue(collection, id, next),
|
||||
position: afterPositions.get(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const collection of CONVERGENT_STRING_COLLECTIONS) {
|
||||
if (!hasDefinedOwnProperty(legacy, collection)) continue;
|
||||
const before = stringSet(baseline, collection);
|
||||
const after = stringSet(legacy, collection);
|
||||
const beforePositions = positionMap(before);
|
||||
const afterPositions = positionMap(after);
|
||||
for (const value of [...before].sort()) {
|
||||
if (!after.has(value)) mutations.push({ kind: 'string-entry-delete', collection, value });
|
||||
}
|
||||
for (const value of [...after].sort()) {
|
||||
if (!before.has(value) || beforePositions.get(value) !== afterPositions.get(value)) {
|
||||
mutations.push({
|
||||
kind: 'string-entry-add',
|
||||
collection,
|
||||
value,
|
||||
position: afterPositions.get(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasDefinedOwnProperty(legacy, 'settings')) {
|
||||
const before = flattenSettings(baseline.settings);
|
||||
const after = flattenSettings(legacy.settings);
|
||||
const paths = new Set([...before.keys(), ...after.keys()]);
|
||||
for (const encodedPath of [...paths].sort()) {
|
||||
const previous = before.get(encodedPath);
|
||||
const next = after.get(encodedPath);
|
||||
if (previous && !next) {
|
||||
mutations.push({ kind: 'setting-delete', path: previous.path });
|
||||
} else if (next && (!previous || fingerprint(previous.value) !== fingerprint(next.value))) {
|
||||
mutations.push({ kind: 'setting-set', path: next.path, value: next.value });
|
||||
}
|
||||
}
|
||||
}
|
||||
return mutations;
|
||||
}
|
||||
|
||||
export function applyLegacySyncPayload(
|
||||
state: ConvergentSyncStateV2,
|
||||
baseline: SyncPayload,
|
||||
legacy: SyncPayload,
|
||||
syntheticDeviceId: string,
|
||||
now: number,
|
||||
): ConvergentSyncStateV2 {
|
||||
return applyConvergentMutations(
|
||||
state,
|
||||
syntheticDeviceId,
|
||||
diffLegacySyncPayload(baseline, legacy),
|
||||
now,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user