diff --git a/electron/remote-sync-entities.cjs b/electron/remote-sync-entities.cjs index a60ce8c43..8bda0be61 100644 --- a/electron/remote-sync-entities.cjs +++ b/electron/remote-sync-entities.cjs @@ -10,6 +10,9 @@ const SYNCED_ENTITY_TYPES = Object.freeze([ "dashboardServiceLinks", "homepageItems", "userPreferences", + // networkTopology stores host references by their local numeric id inside + // the topology JSON, so it must sync after hosts have been reconciled. + "networkTopology", ]); module.exports = { SYNCED_ENTITY_TYPES }; diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts index a0378aaee..19af5e64a 100644 --- a/src/backend/database/repositories/sync-tombstone-repository.ts +++ b/src/backend/database/repositories/sync-tombstone-repository.ts @@ -14,7 +14,8 @@ export type SyncEntityType = | "vaultProfiles" | "dashboardServiceLinks" | "homepageItems" - | "userPreferences"; + | "userPreferences" + | "networkTopology"; export class SyncTombstoneRepository { constructor( diff --git a/src/backend/database/routes/sync-references.ts b/src/backend/database/routes/sync-references.ts index 1c4e0dc81..a10e4132f 100644 --- a/src/backend/database/routes/sync-references.ts +++ b/src/backend/database/routes/sync-references.ts @@ -2,6 +2,79 @@ import type { SyncEntityType } from "../repositories/sync-tombstone-repository.j export type SyncReferenceEntity = "hosts" | "sshCredentials" | "vaultProfiles"; +interface TopologyNode { + data?: { id?: string; [key: string]: unknown }; + [key: string]: unknown; +} + +interface TopologyEdge { + data?: { source?: string; target?: string; [key: string]: unknown }; + [key: string]: unknown; +} + +interface Topology { + nodes?: TopologyNode[]; + edges?: TopologyEdge[]; + [key: string]: unknown; +} + +/** + * Node/edge ids in a topology are either a host's local numeric `id` (as a + * string) or a client-generated `group-` id. Only the former is + * meaningful across sides, so translate just those through the mapper and + * leave group ids untouched. Nodes/edges that fail to resolve (host deleted, + * or not synced to this side yet) are dropped rather than left dangling. + */ +async function mapTopologyHostIds( + topologyJson: string | null | undefined, + mapId: (id: string) => Promise, +): Promise { + if (!topologyJson) return topologyJson ?? null; + + let topology: Topology; + try { + topology = JSON.parse(topologyJson); + } catch { + return topologyJson; + } + + const isGroupId = (id: string) => id.startsWith("group-"); + const idMap = new Map(); + + const resolve = async (id: string): Promise => { + if (isGroupId(id)) return id; + if (!idMap.has(id)) idMap.set(id, await mapId(id)); + return idMap.get(id) ?? null; + }; + + const nodes: TopologyNode[] = []; + for (const node of topology.nodes ?? []) { + const id = node.data?.id; + if (typeof id !== "string") continue; + const mapped = await resolve(id); + if (mapped === null) continue; + nodes.push({ ...node, data: { ...node.data, id: mapped } }); + } + + const nodeIds = new Set(nodes.map((n) => n.data?.id)); + const edges: TopologyEdge[] = []; + for (const edge of topology.edges ?? []) { + const source = edge.data?.source; + const target = edge.data?.target; + if (typeof source !== "string" || typeof target !== "string") continue; + const mappedSource = await resolve(source); + const mappedTarget = await resolve(target); + if (mappedSource === null || mappedTarget === null) continue; + if (!nodeIds.has(mappedSource) || !nodeIds.has(mappedTarget)) continue; + edges.push({ + ...edge, + data: { ...edge.data, source: mappedSource, target: mappedTarget }, + }); + } + + return JSON.stringify({ ...topology, nodes, edges }); +} + interface SyncReference { field: string; syncField: string; @@ -92,6 +165,19 @@ export async function serializeSyncReferences( ) => Promise, ): Promise> { const serialized = { ...row }; + + if (entityType === "networkTopology") { + serialized.topology = await mapTopologyHostIds( + serialized.topology as string | null | undefined, + async (id) => { + const numericId = Number(id); + if (!Number.isInteger(numericId)) return null; + return resolveSyncId("hosts", numericId); + }, + ); + return serialized; + } + for (const reference of REFERENCES[entityType] ?? []) { const id = serialized[reference.field]; serialized[reference.syncField] = @@ -112,6 +198,18 @@ export async function deserializeSyncReferences( ) => Promise, ): Promise> { const deserialized = { ...row }; + + if (entityType === "networkTopology") { + deserialized.topology = await mapTopologyHostIds( + deserialized.topology as string | null | undefined, + async (syncId) => { + const id = await resolveId("hosts", syncId); + return id === null ? null : String(id); + }, + ); + return deserialized; + } + for (const reference of REFERENCES[entityType] ?? []) { const syncId = deserialized[reference.syncField]; delete deserialized[reference.syncField]; diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts index c3d4bcf19..509b323f8 100644 --- a/src/backend/database/routes/sync.ts +++ b/src/backend/database/routes/sync.ts @@ -10,6 +10,7 @@ import { dashboardServiceLinks, homepageItems, userPreferences, + networkTopology, } from "../db/schema.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; @@ -52,7 +53,8 @@ interface EntityConfig { | typeof vaultProfiles | typeof dashboardServiceLinks | typeof homepageItems - | typeof userPreferences; + | typeof userPreferences + | typeof networkTopology; // Fields that only make sense on the device that created the row, or // that are managed elsewhere and must never be overwritten by a sync // payload from the other side. @@ -77,6 +79,11 @@ const ENTITY_CONFIG: Record = { readOnlyFields: ["storageMode"], singleton: true, }, + networkTopology: { + table: networkTopology, + readOnlyFields: [], + singleton: true, + }, }; const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));