[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:
陈大猫
2026-06-01 18:40:19 +08:00
committed by GitHub
parent 463dd4464f
commit 3c4746aea0
13 changed files with 1425 additions and 119 deletions

View File

@@ -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;
}
/**

View File

@@ -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" }]);
});

View File

@@ -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,

View 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
View 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),
);
}