[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:
355
domain/syncReliability.ts
Normal file
355
domain/syncReliability.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
withHostsSanitizedForSync,
|
||||
type CloudSyncPayloadEntityKey,
|
||||
type SyncChangeEntityKey,
|
||||
type SyncChangeSummary,
|
||||
type SyncDeletionRecord,
|
||||
type SyncEntityChangeCounts,
|
||||
type SyncPayload,
|
||||
type SyncReliabilityMeta,
|
||||
} from './sync';
|
||||
|
||||
type EntityValue = { id?: string; path?: string } | string;
|
||||
|
||||
export const SYNC_SNAPSHOT_LIMIT = 5;
|
||||
|
||||
const EMPTY_COUNTS = (): SyncEntityChangeCounts => ({
|
||||
added: { local: 0, remote: 0 },
|
||||
modified: { local: 0, remote: 0 },
|
||||
deleted: { local: 0, remote: 0 },
|
||||
});
|
||||
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippetPackages',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
]);
|
||||
|
||||
function fingerprint(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
|
||||
acc[k] = (v as Record<string, unknown>)[k];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
function entityId(value: EntityValue): string {
|
||||
return typeof value === 'string' ? value : value.id ?? value.path ?? '';
|
||||
}
|
||||
|
||||
function entityMap(values: unknown): Map<string, EntityValue> {
|
||||
if (!Array.isArray(values)) return new Map();
|
||||
return new Map(
|
||||
values
|
||||
.filter((value): value is EntityValue =>
|
||||
typeof value === 'string'
|
||||
|| (
|
||||
Boolean(value)
|
||||
&& typeof value === 'object'
|
||||
&& (
|
||||
typeof (value as { id?: unknown }).id === 'string'
|
||||
|| typeof (value as { path?: unknown }).path === 'string'
|
||||
)
|
||||
),
|
||||
)
|
||||
.map((value) => [entityId(value), value]),
|
||||
);
|
||||
}
|
||||
|
||||
function payloadValues(
|
||||
payload: SyncPayload,
|
||||
entityType: CloudSyncPayloadEntityKey,
|
||||
base?: SyncPayload,
|
||||
): unknown {
|
||||
if (
|
||||
base
|
||||
&& OPTIONAL_ENTITY_KEYS.has(entityType)
|
||||
&& !Object.prototype.hasOwnProperty.call(payload, entityType)
|
||||
) {
|
||||
return base[entityType];
|
||||
}
|
||||
return payload[entityType];
|
||||
}
|
||||
|
||||
function incrementEntity(
|
||||
summary: SyncChangeSummary,
|
||||
entityType: SyncChangeEntityKey,
|
||||
updater: (counts: SyncEntityChangeCounts) => void,
|
||||
): void {
|
||||
const counts = summary.byEntity[entityType] ?? EMPTY_COUNTS();
|
||||
updater(counts);
|
||||
summary.byEntity[entityType] = counts;
|
||||
}
|
||||
|
||||
function recordEntityChanges(
|
||||
summary: SyncChangeSummary,
|
||||
entityType: CloudSyncPayloadEntityKey,
|
||||
baseValues: unknown,
|
||||
localValues: unknown,
|
||||
remoteValues?: unknown,
|
||||
): void {
|
||||
const base = entityMap(baseValues);
|
||||
const local = entityMap(localValues);
|
||||
const remote = remoteValues === undefined ? null : entityMap(remoteValues);
|
||||
const ids = new Set([
|
||||
...base.keys(),
|
||||
...local.keys(),
|
||||
...(remote ? remote.keys() : []),
|
||||
]);
|
||||
|
||||
for (const id of ids) {
|
||||
const baseItem = base.get(id);
|
||||
const localItem = local.get(id);
|
||||
const remoteItem = remote?.get(id);
|
||||
const localAdded = !baseItem && Boolean(localItem);
|
||||
const localDeleted = Boolean(baseItem) && !localItem;
|
||||
const localModified = Boolean(baseItem && localItem)
|
||||
&& fingerprint(baseItem) !== fingerprint(localItem);
|
||||
|
||||
if (localAdded) {
|
||||
summary.hasLocalChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.added.local += 1; });
|
||||
}
|
||||
if (localDeleted) {
|
||||
summary.hasLocalChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.deleted.local += 1; });
|
||||
}
|
||||
if (localModified) {
|
||||
summary.hasLocalChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.modified.local += 1; });
|
||||
}
|
||||
|
||||
if (!remote) continue;
|
||||
|
||||
const remoteAdded = !baseItem && Boolean(remoteItem);
|
||||
const remoteDeleted = Boolean(baseItem) && !remoteItem;
|
||||
const remoteModified = Boolean(baseItem && remoteItem)
|
||||
&& fingerprint(baseItem) !== fingerprint(remoteItem);
|
||||
|
||||
if (remoteAdded) {
|
||||
summary.hasRemoteChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.added.remote += 1; });
|
||||
}
|
||||
if (remoteDeleted) {
|
||||
summary.hasRemoteChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.deleted.remote += 1; });
|
||||
}
|
||||
if (remoteModified) {
|
||||
summary.hasRemoteChanges = true;
|
||||
incrementEntity(summary, entityType, (counts) => { counts.modified.remote += 1; });
|
||||
}
|
||||
|
||||
if (localAdded && remoteAdded && fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
summary.hasConflicts = true;
|
||||
summary.conflicts.push({ entityType, id, kind: 'both-added' });
|
||||
} else if (localModified && remoteModified && fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
summary.hasConflicts = true;
|
||||
summary.conflicts.push({ entityType, id, kind: 'both-modified' });
|
||||
} else if (localDeleted && remoteModified) {
|
||||
summary.hasConflicts = true;
|
||||
summary.conflicts.push({ entityType, id, kind: 'local-deleted-remote-modified' });
|
||||
} else if (remoteDeleted && localModified) {
|
||||
summary.hasConflicts = true;
|
||||
summary.conflicts.push({ entityType, id, kind: 'remote-deleted-local-modified' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordSettingsChanges(
|
||||
summary: SyncChangeSummary,
|
||||
base: SyncPayload,
|
||||
local: SyncPayload,
|
||||
remote?: SyncPayload,
|
||||
): void {
|
||||
const localChanged = fingerprint(local.settings) !== fingerprint(base.settings);
|
||||
const remoteChanged = remote !== undefined
|
||||
&& fingerprint(remote.settings) !== fingerprint(base.settings);
|
||||
|
||||
if (localChanged) {
|
||||
summary.hasLocalChanges = true;
|
||||
incrementEntity(summary, 'settings', (counts) => { counts.modified.local += 1; });
|
||||
}
|
||||
if (remoteChanged) {
|
||||
summary.hasRemoteChanges = true;
|
||||
incrementEntity(summary, 'settings', (counts) => { counts.modified.remote += 1; });
|
||||
}
|
||||
if (
|
||||
localChanged
|
||||
&& remoteChanged
|
||||
&& fingerprint(local.settings) !== fingerprint(remote?.settings)
|
||||
) {
|
||||
summary.hasConflicts = true;
|
||||
summary.conflicts.push({ entityType: 'settings', kind: 'both-modified' });
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeSyncChanges(
|
||||
base: SyncPayload | null,
|
||||
local: SyncPayload,
|
||||
remote?: SyncPayload,
|
||||
): SyncChangeSummary {
|
||||
const emptyBase: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
const reference = withHostsSanitizedForSync(base ?? emptyBase);
|
||||
const localPayload = withHostsSanitizedForSync(local);
|
||||
const remotePayload = remote ? withHostsSanitizedForSync(remote) : undefined;
|
||||
const summary: SyncChangeSummary = {
|
||||
hasLocalChanges: false,
|
||||
hasRemoteChanges: false,
|
||||
hasConflicts: false,
|
||||
byEntity: {},
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
for (const entityType of CLOUD_SYNC_PAYLOAD_ENTITY_KEYS) {
|
||||
recordEntityChanges(
|
||||
summary,
|
||||
entityType,
|
||||
reference[entityType],
|
||||
payloadValues(localPayload, entityType, reference),
|
||||
remotePayload ? payloadValues(remotePayload, entityType, reference) : undefined,
|
||||
);
|
||||
}
|
||||
recordSettingsChanges(summary, reference, localPayload, remotePayload);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function collectSyncDeletions(
|
||||
base: SyncPayload | null,
|
||||
current: SyncPayload,
|
||||
opts: { deletedAt: number; deviceId?: string },
|
||||
): SyncDeletionRecord[] {
|
||||
if (!base) return [];
|
||||
const deletions: SyncDeletionRecord[] = [];
|
||||
|
||||
for (const entityType of CLOUD_SYNC_PAYLOAD_ENTITY_KEYS) {
|
||||
const baseItems = entityMap(base[entityType]);
|
||||
const currentItems = entityMap(payloadValues(current, entityType, base));
|
||||
for (const id of baseItems.keys()) {
|
||||
if (!currentItems.has(id)) {
|
||||
deletions.push({
|
||||
entityType,
|
||||
id,
|
||||
deletedAt: opts.deletedAt,
|
||||
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletions;
|
||||
}
|
||||
|
||||
function mergeDeletionRecords(
|
||||
payload: SyncPayload,
|
||||
newDeletions: SyncDeletionRecord[],
|
||||
): SyncDeletionRecord[] {
|
||||
const byKey = new Map<string, SyncDeletionRecord>();
|
||||
for (const record of [...(payload.syncMeta?.deletions ?? []), ...newDeletions]) {
|
||||
const currentItems = entityMap(payload[record.entityType]);
|
||||
if (currentItems.has(record.id)) continue;
|
||||
const key = `${record.entityType}:${record.id}`;
|
||||
const previous = byKey.get(key);
|
||||
if (!previous || record.deletedAt >= previous.deletedAt) {
|
||||
byKey.set(key, record);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values()).sort((a, b) =>
|
||||
a.entityType.localeCompare(b.entityType) || a.id.localeCompare(b.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function withSyncReliabilityMeta(
|
||||
payload: SyncPayload,
|
||||
base: SyncPayload | null,
|
||||
opts: { deviceId?: string; now?: number } = {},
|
||||
): SyncPayload {
|
||||
const generatedAt = opts.now ?? Date.now();
|
||||
const changeSummary = summarizeSyncChanges(base, payload);
|
||||
const deletions = mergeDeletionRecords(
|
||||
payload,
|
||||
collectSyncDeletions(base, payload, {
|
||||
deletedAt: generatedAt,
|
||||
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
|
||||
}),
|
||||
);
|
||||
const meta: SyncReliabilityMeta = {
|
||||
schemaVersion: 1,
|
||||
generatedAt,
|
||||
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
|
||||
...(base?.syncedAt ? { baseSyncedAt: base.syncedAt } : {}),
|
||||
localChanged: changeSummary.hasLocalChanges,
|
||||
deletions,
|
||||
changeSummary,
|
||||
};
|
||||
|
||||
return {
|
||||
...payload,
|
||||
syncMeta: meta,
|
||||
};
|
||||
}
|
||||
|
||||
export function carryForwardSyncDeletions(
|
||||
payload: SyncPayload,
|
||||
sources: SyncPayload[],
|
||||
opts: { generatedAt?: number; deviceId?: string } = {},
|
||||
): SyncPayload {
|
||||
const sourceDeletions = sources.flatMap((source) => source.syncMeta?.deletions ?? []);
|
||||
const deletions = mergeDeletionRecords(
|
||||
{
|
||||
...payload,
|
||||
syncMeta: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: opts.generatedAt ?? Date.now(),
|
||||
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
|
||||
localChanged: false,
|
||||
deletions: sourceDeletions,
|
||||
changeSummary: summarizeSyncChanges(null, payload),
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (deletions.length === 0) return payload;
|
||||
|
||||
return {
|
||||
...payload,
|
||||
syncMeta: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: opts.generatedAt ?? Date.now(),
|
||||
...(opts.deviceId ? { deviceId: opts.deviceId } : {}),
|
||||
localChanged: false,
|
||||
deletions,
|
||||
changeSummary: summarizeSyncChanges(null, payload),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getDeletedEntityIds(
|
||||
payload: SyncPayload,
|
||||
entityType: CloudSyncPayloadEntityKey,
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
(payload.syncMeta?.deletions ?? [])
|
||||
.filter((record) => record.entityType === entityType)
|
||||
.map((record) => record.id),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user