[codex] add sync reliability metadata (#1174)
* feat: add sync reliability metadata * fix: preserve tombstones from checked remotes * fix: keep conflict change counts typed
This commit is contained in:
@@ -242,6 +242,9 @@ export interface SyncPayload {
|
||||
|
||||
// Sync metadata
|
||||
syncedAt: number; // When this payload was created
|
||||
|
||||
// Reliability metadata used to make sync decisions auditable across devices.
|
||||
syncMeta?: SyncReliabilityMeta;
|
||||
}
|
||||
|
||||
export const SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
@@ -271,6 +274,55 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
||||
|
||||
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
|
||||
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
|
||||
export type SyncChangeEntityKey = CloudSyncPayloadEntityKey | 'settings';
|
||||
|
||||
export interface SyncEntityChangeCounts {
|
||||
added: { local: number; remote: number };
|
||||
modified: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
}
|
||||
|
||||
export interface SyncConflictDetail {
|
||||
entityType: SyncChangeEntityKey;
|
||||
id?: string;
|
||||
kind:
|
||||
| 'both-added'
|
||||
| 'both-modified'
|
||||
| 'local-deleted-remote-modified'
|
||||
| 'remote-deleted-local-modified';
|
||||
}
|
||||
|
||||
export interface SyncChangeSummary {
|
||||
hasLocalChanges: boolean;
|
||||
hasRemoteChanges: boolean;
|
||||
hasConflicts: boolean;
|
||||
byEntity: Partial<Record<SyncChangeEntityKey, SyncEntityChangeCounts>>;
|
||||
conflicts: SyncConflictDetail[];
|
||||
}
|
||||
|
||||
export interface SyncDeletionRecord {
|
||||
entityType: CloudSyncPayloadEntityKey;
|
||||
id: string;
|
||||
deletedAt: number;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface SyncReliabilityMeta {
|
||||
schemaVersion: 1;
|
||||
generatedAt: number;
|
||||
deviceId?: string;
|
||||
baseSyncedAt?: number;
|
||||
localChanged: boolean;
|
||||
deletions: SyncDeletionRecord[];
|
||||
changeSummary: SyncChangeSummary;
|
||||
}
|
||||
|
||||
export interface SyncSnapshotEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
provider?: CloudProvider;
|
||||
payload: SyncPayload;
|
||||
}
|
||||
|
||||
export function hasSyncPayloadEntityData(
|
||||
payload: SyncPayload,
|
||||
@@ -375,6 +427,7 @@ export interface ConflictInfo {
|
||||
remoteVersion: number;
|
||||
remoteUpdatedAt: number;
|
||||
remoteDeviceName?: string;
|
||||
changeSummary?: SyncChangeSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mergeSyncPayloads } from "./syncMerge.ts";
|
||||
import { withSyncReliabilityMeta } from "./syncReliability.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
@@ -136,3 +137,138 @@ test("mergeSyncPayloads keeps missing proxy references visible to connection gua
|
||||
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
|
||||
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads honors remote deletion records when base is unavailable", () => {
|
||||
const result = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Stale local copy",
|
||||
hostname: "old.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
}),
|
||||
payload({
|
||||
syncMeta: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: 123,
|
||||
localChanged: true,
|
||||
deletions: [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
deletedAt: 123,
|
||||
deviceId: "remote-device",
|
||||
}],
|
||||
changeSummary: {
|
||||
hasLocalChanges: true,
|
||||
hasRemoteChanges: false,
|
||||
hasConflicts: false,
|
||||
byEntity: {},
|
||||
conflicts: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.payload.hosts, []);
|
||||
assert.equal(result.summary.deleted.remote, 1);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads carries deletion records forward after applying a tombstone", () => {
|
||||
const remote = payload({
|
||||
syncMeta: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: 123,
|
||||
localChanged: true,
|
||||
deletions: [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
deletedAt: 123,
|
||||
deviceId: "remote-device",
|
||||
}],
|
||||
changeSummary: {
|
||||
hasLocalChanges: true,
|
||||
hasRemoteChanges: false,
|
||||
hasConflicts: false,
|
||||
byEntity: {},
|
||||
conflicts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Stale local copy",
|
||||
hostname: "old.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
}),
|
||||
remote,
|
||||
);
|
||||
const enriched = withSyncReliabilityMeta(result.payload, null, {
|
||||
deviceId: "local-device",
|
||||
now: 456,
|
||||
});
|
||||
|
||||
assert.deepEqual(enriched.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
deletedAt: 123,
|
||||
deviceId: "remote-device",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads treats missing optional arrays as legacy payloads, not deletions", () => {
|
||||
const identity = {
|
||||
id: "identity-1",
|
||||
label: "Prod",
|
||||
username: "root",
|
||||
authMethod: "password" as const,
|
||||
created: 1,
|
||||
};
|
||||
const rule = {
|
||||
id: "rule-1",
|
||||
name: "Web",
|
||||
hostId: "host-1",
|
||||
type: "local" as const,
|
||||
localHost: "127.0.0.1",
|
||||
localPort: 8080,
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: 80,
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const base = payload({
|
||||
identities: [identity],
|
||||
snippetPackages: ["ops"],
|
||||
portForwardingRules: [rule],
|
||||
groupConfigs: [{ path: "prod", username: "root" }],
|
||||
});
|
||||
const local = payload({
|
||||
identities: [identity],
|
||||
snippetPackages: ["ops"],
|
||||
portForwardingRules: [rule],
|
||||
groupConfigs: [{ path: "prod", username: "root" }],
|
||||
});
|
||||
const remote = payload();
|
||||
delete remote.identities;
|
||||
delete remote.snippetPackages;
|
||||
delete remote.portForwardingRules;
|
||||
delete remote.groupConfigs;
|
||||
|
||||
const result = mergeSyncPayloads(base, local, remote);
|
||||
|
||||
assert.deepEqual(result.payload.identities, [identity]);
|
||||
assert.deepEqual(result.payload.snippetPackages, ["ops"]);
|
||||
assert.deepEqual(result.payload.portForwardingRules, [rule]);
|
||||
assert.deepEqual(result.payload.groupConfigs, [{ path: "prod", username: "root" }]);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* merge by entity ID, preferring local for duplicates.
|
||||
*/
|
||||
|
||||
import type { SyncPayload } from './sync';
|
||||
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
|
||||
import type { CloudSyncPayloadEntityKey, SyncPayload } from './sync';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
@@ -38,6 +39,14 @@ interface MergeResult {
|
||||
summary: MergeSummary;
|
||||
}
|
||||
|
||||
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippetPackages',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +67,21 @@ function fingerprint(value: unknown): string {
|
||||
});
|
||||
}
|
||||
|
||||
function entityArray<T>(
|
||||
payload: SyncPayload,
|
||||
key: CloudSyncPayloadEntityKey,
|
||||
fallback: T[],
|
||||
): T[] {
|
||||
if (
|
||||
OPTIONAL_ENTITY_KEYS.has(key)
|
||||
&& !Object.prototype.hasOwnProperty.call(payload, key)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
const value = payload[key];
|
||||
return Array.isArray(value) ? value as T[] : [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity-array merge (hosts, keys, identities, snippets, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,6 +98,7 @@ function mergeEntityArrays<T extends { id: string }>(
|
||||
base: T[],
|
||||
local: T[],
|
||||
remote: T[],
|
||||
tombstones?: { local: Set<string>; remote: Set<string> },
|
||||
): EntityMergeResult<T> {
|
||||
const baseMap = new Map(base.map((e) => [e.id, e]));
|
||||
const localMap = new Map(local.map((e) => [e.id, e]));
|
||||
@@ -100,7 +125,14 @@ function mergeEntityArrays<T extends { id: string }>(
|
||||
const inLocal = localItem !== undefined;
|
||||
const inRemote = remoteItem !== undefined;
|
||||
|
||||
if (!inBase && inLocal && !inRemote) {
|
||||
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(id)) {
|
||||
// Remote explicitly records this entity as deleted. When no base is
|
||||
// available, this tombstone is the only durable signal that absence is
|
||||
// intentional rather than an old client omitting the entity.
|
||||
deleted.remote++;
|
||||
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(id)) {
|
||||
deleted.local++;
|
||||
} else if (!inBase && inLocal && !inRemote) {
|
||||
// Local addition
|
||||
merged.push(localItem);
|
||||
added.local++;
|
||||
@@ -171,6 +203,7 @@ function mergeStringArrays(
|
||||
base: string[],
|
||||
local: string[],
|
||||
remote: string[],
|
||||
tombstones?: { local: Set<string>; remote: Set<string> },
|
||||
): string[] {
|
||||
const baseSet = new Set(base);
|
||||
const localSet = new Set(local);
|
||||
@@ -186,7 +219,11 @@ function mergeStringArrays(
|
||||
const inLocal = localSet.has(value);
|
||||
const inRemote = remoteSet.has(value);
|
||||
|
||||
if (!inBase) {
|
||||
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(value)) {
|
||||
// Remote tombstone wins over a stale local value when no base exists.
|
||||
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(value)) {
|
||||
// Local tombstone wins over a stale remote value when no base exists.
|
||||
} else if (!inBase) {
|
||||
// Addition — keep if either side added it
|
||||
if (inLocal || inRemote) result.add(value);
|
||||
} else {
|
||||
@@ -359,22 +396,35 @@ export function mergeSyncPayloads(
|
||||
deleted: { local: 0, remote: 0 },
|
||||
modified: { local: 0, remote: 0, conflicts: 0 },
|
||||
};
|
||||
const tombstones = (entityType: CloudSyncPayloadEntityKey) => ({
|
||||
local: getDeletedEntityIds(local, entityType),
|
||||
remote: getDeletedEntityIds(remote, entityType),
|
||||
});
|
||||
|
||||
// Merge each entity type
|
||||
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
|
||||
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
|
||||
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? [], tombstones('hosts'));
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? [], tombstones('keys'));
|
||||
const baseIdentities = b.identities ?? [];
|
||||
const identities = mergeEntityArrays(
|
||||
baseIdentities,
|
||||
entityArray(local, 'identities', baseIdentities),
|
||||
entityArray(remote, 'identities', baseIdentities),
|
||||
tombstones('identities'),
|
||||
);
|
||||
const baseProxyProfiles = b.proxyProfiles ?? [];
|
||||
const proxyProfiles = mergeEntityArrays(
|
||||
baseProxyProfiles,
|
||||
local.proxyProfiles ?? baseProxyProfiles,
|
||||
remote.proxyProfiles ?? baseProxyProfiles,
|
||||
entityArray(local, 'proxyProfiles', baseProxyProfiles),
|
||||
entityArray(remote, 'proxyProfiles', baseProxyProfiles),
|
||||
tombstones('proxyProfiles'),
|
||||
);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? [], tombstones('snippets'));
|
||||
const basePortForwardingRules = b.portForwardingRules ?? [];
|
||||
const portForwardingRules = mergeEntityArrays(
|
||||
b.portForwardingRules ?? [],
|
||||
local.portForwardingRules ?? [],
|
||||
remote.portForwardingRules ?? [],
|
||||
basePortForwardingRules,
|
||||
entityArray(local, 'portForwardingRules', basePortForwardingRules),
|
||||
entityArray(remote, 'portForwardingRules', basePortForwardingRules),
|
||||
tombstones('portForwardingRules'),
|
||||
);
|
||||
|
||||
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
|
||||
@@ -386,8 +436,9 @@ export function mergeSyncPayloads(
|
||||
const baseGroupConfigs = b.groupConfigs ?? [];
|
||||
const groupConfigsResult = mergeEntityArrays(
|
||||
wrapGC(baseGroupConfigs),
|
||||
wrapGC(local.groupConfigs ?? baseGroupConfigs),
|
||||
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
|
||||
wrapGC(entityArray(local, 'groupConfigs', baseGroupConfigs)),
|
||||
wrapGC(entityArray(remote, 'groupConfigs', baseGroupConfigs)),
|
||||
tombstones('groupConfigs'),
|
||||
);
|
||||
|
||||
// Aggregate stats
|
||||
@@ -408,11 +459,14 @@ export function mergeSyncPayloads(
|
||||
b.customGroups ?? [],
|
||||
local.customGroups ?? [],
|
||||
remote.customGroups ?? [],
|
||||
tombstones('customGroups'),
|
||||
);
|
||||
const baseSnippetPackages = b.snippetPackages ?? [];
|
||||
const snippetPackages = mergeStringArrays(
|
||||
b.snippetPackages ?? [],
|
||||
local.snippetPackages ?? [],
|
||||
remote.snippetPackages ?? [],
|
||||
baseSnippetPackages,
|
||||
entityArray<string>(local, 'snippetPackages', baseSnippetPackages),
|
||||
entityArray<string>(remote, 'snippetPackages', baseSnippetPackages),
|
||||
tombstones('snippetPackages'),
|
||||
);
|
||||
|
||||
// Merge settings
|
||||
@@ -430,7 +484,7 @@ export function mergeSyncPayloads(
|
||||
|
||||
const groupConfigs = unwrapGC(groupConfigsResult.merged);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
const payload: SyncPayload = carryForwardSyncDeletions({
|
||||
hosts: hosts.merged,
|
||||
keys: keys.merged,
|
||||
identities: identities.merged,
|
||||
@@ -442,7 +496,7 @@ export function mergeSyncPayloads(
|
||||
groupConfigs,
|
||||
settings,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [local, remote]);
|
||||
|
||||
return {
|
||||
payload,
|
||||
|
||||
253
domain/syncReliability.test.ts
Normal file
253
domain/syncReliability.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
collectSyncDeletions,
|
||||
summarizeSyncChanges,
|
||||
withSyncReliabilityMeta,
|
||||
} from "./syncReliability.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("summarizeSyncChanges records whether local data changed", () => {
|
||||
const base = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Old",
|
||||
hostname: "old.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
const local = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "New",
|
||||
hostname: "old.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
|
||||
const summary = summarizeSyncChanges(base, local);
|
||||
|
||||
assert.equal(summary.hasLocalChanges, true);
|
||||
assert.equal(summary.byEntity.hosts.modified.local, 1);
|
||||
});
|
||||
|
||||
test("collectSyncDeletions records deleted entities explicitly", () => {
|
||||
const base = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Old",
|
||||
hostname: "old.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
customGroups: ["prod"],
|
||||
});
|
||||
|
||||
const deletions = collectSyncDeletions(base, payload(), {
|
||||
deletedAt: 123,
|
||||
deviceId: "device-a",
|
||||
});
|
||||
|
||||
assert.deepEqual(deletions, [
|
||||
{ entityType: "hosts", id: "host-1", deletedAt: 123, deviceId: "device-a" },
|
||||
{ entityType: "customGroups", id: "prod", deletedAt: 123, deviceId: "device-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("collectSyncDeletions records group config deletions by path", () => {
|
||||
const deletions = collectSyncDeletions(
|
||||
payload({ groupConfigs: [{ path: "prod", username: "root" }] }),
|
||||
payload({ groupConfigs: [] }),
|
||||
{ deletedAt: 123 },
|
||||
);
|
||||
|
||||
assert.deepEqual(deletions, [{
|
||||
entityType: "groupConfigs",
|
||||
id: "prod",
|
||||
deletedAt: 123,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("summarizeSyncChanges treats missing optional arrays as legacy payloads", () => {
|
||||
const base = payload({
|
||||
groupConfigs: [{ path: "prod", username: "root" }],
|
||||
});
|
||||
const remote = payload();
|
||||
delete remote.groupConfigs;
|
||||
|
||||
const summary = summarizeSyncChanges(base, base, remote);
|
||||
|
||||
assert.equal(summary.hasRemoteChanges, false);
|
||||
assert.equal(summary.byEntity.groupConfigs, undefined);
|
||||
});
|
||||
|
||||
test("summarizeSyncChanges reports conflict categories", () => {
|
||||
const base = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Base",
|
||||
hostname: "base.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
const local = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Local",
|
||||
hostname: "base.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
const remote = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Remote",
|
||||
hostname: "base.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
|
||||
const summary = summarizeSyncChanges(base, local, remote);
|
||||
|
||||
assert.equal(summary.hasConflicts, true);
|
||||
assert.deepEqual(summary.conflicts, [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
kind: "both-modified",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("summarizeSyncChanges reports both-added conflicts by entity type", () => {
|
||||
const local = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Local",
|
||||
hostname: "local.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
const remote = payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Remote",
|
||||
hostname: "remote.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
});
|
||||
|
||||
const summary = summarizeSyncChanges(payload(), local, remote);
|
||||
|
||||
assert.equal(summary.hasConflicts, true);
|
||||
assert.deepEqual(summary.conflicts, [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
kind: "both-added",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("withSyncReliabilityMeta carries old deletion records until the entity is recreated", () => {
|
||||
const current = payload({
|
||||
syncMeta: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: 100,
|
||||
localChanged: true,
|
||||
deletions: [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
deletedAt: 100,
|
||||
deviceId: "device-a",
|
||||
}],
|
||||
changeSummary: {
|
||||
hasLocalChanges: true,
|
||||
hasRemoteChanges: false,
|
||||
hasConflicts: false,
|
||||
byEntity: {},
|
||||
conflicts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enriched = withSyncReliabilityMeta(current, payload(), {
|
||||
deviceId: "device-a",
|
||||
now: 200,
|
||||
});
|
||||
|
||||
assert.deepEqual(enriched.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "host-1",
|
||||
deletedAt: 100,
|
||||
deviceId: "device-a",
|
||||
}]);
|
||||
|
||||
const recreated = withSyncReliabilityMeta(
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Recreated",
|
||||
hostname: "new.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
}],
|
||||
syncMeta: enriched.syncMeta,
|
||||
}),
|
||||
payload(),
|
||||
{ deviceId: "device-a", now: 300 },
|
||||
);
|
||||
|
||||
assert.deepEqual(recreated.syncMeta?.deletions, []);
|
||||
});
|
||||
|
||||
test("withSyncReliabilityMeta attaches change summary and deletion records", () => {
|
||||
const base = payload({
|
||||
snippets: [{ id: "snippet-1", label: "Old", command: "ls" }],
|
||||
});
|
||||
const current = payload();
|
||||
|
||||
const enriched = withSyncReliabilityMeta(current, base, {
|
||||
deviceId: "device-a",
|
||||
now: 456,
|
||||
});
|
||||
|
||||
assert.equal(enriched.syncMeta?.schemaVersion, 1);
|
||||
assert.equal(enriched.syncMeta?.localChanged, true);
|
||||
assert.deepEqual(enriched.syncMeta?.deletions, [{
|
||||
entityType: "snippets",
|
||||
id: "snippet-1",
|
||||
deletedAt: 456,
|
||||
deviceId: "device-a",
|
||||
}]);
|
||||
});
|
||||
351
domain/syncReliability.ts
Normal file
351
domain/syncReliability.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
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 reference = base ?? {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
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(local, entityType, reference),
|
||||
remote ? payloadValues(remote, entityType, reference) : undefined,
|
||||
);
|
||||
}
|
||||
recordSettingsChanges(summary, reference, local, remote);
|
||||
|
||||
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