From 97220b5b887f60fec033177547adeff416f9ed3b Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 13 Sep 2024 10:24:08 +0100 Subject: [PATCH] feat: network sync - add main sync logic (#4694) ## Explanation This adds support for the "main-sync" on network syncing feature. This will get all a users local networks, and saved remote networks; then will append/update/delete networks synced across their devices. We will add the controller integration in a following PR. ## References https://consensyssoftware.atlassian.net/browse/NOTIFY-1040 ## Changelog ### `@metamask/profile-sync-controller` - **CHANGED**: Renamed the `sync.ts` file to `sync-mutations.ts` - **ADDED**: `utils` to the `UserStorageController` subpath. - **ADDED**: `findNetworksToUpdate()` function which will perform the "main-sync" logic for local and remote network configurations. ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate --- .../__fixtures__/mockNetwork.ts | 9 +- .../controller-integration.test.ts | 2 +- .../network-syncing/controller-integration.ts | 2 +- .../network-syncing/sync-all.test.ts | 383 ++++++++++++++++++ .../user-storage/network-syncing/sync-all.ts | 241 +++++++++++ .../{sync.test.ts => sync-mutations.test.ts} | 2 +- .../{sync.ts => sync-mutations.ts} | 14 +- .../user-storage/network-syncing/types.ts | 23 +- .../controllers/user-storage/utils.test.ts | 29 ++ .../src/controllers/user-storage/utils.ts | 28 ++ 10 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts rename packages/profile-sync-controller/src/controllers/user-storage/network-syncing/{sync.test.ts => sync-mutations.test.ts} (99%) rename packages/profile-sync-controller/src/controllers/user-storage/network-syncing/{sync.ts => sync-mutations.ts} (74%) create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/utils.test.ts create mode 100644 packages/profile-sync-controller/src/controllers/user-storage/utils.ts diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/__fixtures__/mockNetwork.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/__fixtures__/mockNetwork.ts index 417af73d5b..373c7d117d 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/__fixtures__/mockNetwork.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/__fixtures__/mockNetwork.ts @@ -1,6 +1,7 @@ -import type { NetworkConfiguration } from '@metamask/network-controller'; - -import type { RemoteNetworkConfiguration } from '../types'; +import type { + NetworkConfiguration, + RemoteNetworkConfiguration, +} from '../types'; export const createMockNetworkConfiguration = ( override?: Partial, @@ -18,7 +19,7 @@ export const createMockNetworkConfiguration = ( }; export const createMockRemoteNetworkConfiguration = ( - override?: Partial, + override?: Partial, ): RemoteNetworkConfiguration => { return { v: '1', diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts index 827f0b685c..162d865dca 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.test.ts @@ -8,7 +8,7 @@ import { waitFor } from '../__fixtures__/test-utils'; import type { UserStorageBaseOptions } from '../services'; import { createMockNetworkConfiguration } from './__fixtures__/mockNetwork'; import { startNetworkSyncing } from './controller-integration'; -import * as SyncModule from './sync'; +import * as SyncModule from './sync-mutations'; jest.mock('loglevel', () => { const actual = jest.requireActual('loglevel'); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts index a7b77fe91a..901046a295 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/controller-integration.ts @@ -2,7 +2,7 @@ import log from 'loglevel'; import type { UserStorageBaseOptions } from '../services'; import type { UserStorageControllerMessenger } from '../UserStorageController'; -import { addNetwork, deleteNetwork, updateNetwork } from './sync'; +import { addNetwork, deleteNetwork, updateNetwork } from './sync-mutations'; type SetupNetworkSyncingProps = { messenger: UserStorageControllerMessenger; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts new file mode 100644 index 0000000000..9702f641ad --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.test.ts @@ -0,0 +1,383 @@ +import { + createMockNetworkConfiguration, + createMockRemoteNetworkConfiguration, +} from './__fixtures__/mockNetwork'; +import { + checkWhichNetworkIsLatest, + findNetworksToUpdate, + getDataStructures, + getMissingNetworkLists, + getNewLocalNetworks, + getUpdatedNetworkLists, +} from './sync-all'; +import type { NetworkConfiguration, RemoteNetworkConfiguration } from './types'; + +/** + * This is not used externally, but meant to check logic is consistent + */ +describe('getDataStructures()', () => { + it('should return list of underlying data structures for main sync', () => { + const localNetworks = arrangeLocalNetworks(['1', '2', '3']); + const remoteNetworks = arrangeRemoteNetworks(['3', '4', '5']); + remoteNetworks[1].d = true; // test that a network was deleted + + const result = getDataStructures(localNetworks, remoteNetworks); + + expect(result.localMap.size).toBe(3); + expect(result.remoteMap.size).toBe(3); + expect(result.localKeySet.size).toBe(3); + expect(result.remoteMap.size).toBe(3); + expect(result.existingRemoteKeySet.size).toBe(2); // a remote network was marked as deleted + }); +}); + +/** + * This is not used externally, but meant to check logic is consistent + */ +describe('getMissingNetworkLists()', () => { + it('should return the difference/missing lists from local and remote', () => { + const localNetworks = arrangeLocalNetworks(['1', '2', '3']); + const remoteNetworks = arrangeRemoteNetworks(['3', '4', '5']); + remoteNetworks[1].d = true; // test that a network was deleted + + const ds = getDataStructures(localNetworks, remoteNetworks); + const result = getMissingNetworkLists(ds); + + expect(result.missingRemoteNetworks.map((n) => n.chainId)).toStrictEqual([ + '0x1', + '0x2', + ]); + expect(result.missingLocalNetworks.map((n) => n.chainId)).toStrictEqual([ + '0x5', // 0x4 was deleted, so is not a missing local network + ]); + }); +}); + +const date1 = Date.now(); +const date2 = date1 - 1000 * 60 * 2; +const testMatrix = [ + { + test: `both don't have updatedAt property`, + dates: [null, null] as const, + actual: 'Do Nothing' as const, + }, + { + test: 'local has updatedAt property', + dates: [date1, null] as const, + actual: 'Local Wins' as const, + }, + { + test: 'remote has updatedAt property', + dates: [null, date1] as const, + actual: 'Remote Wins' as const, + }, + { + test: 'both have equal updateAt properties', + dates: [date1, date1] as const, + actual: 'Do Nothing' as const, + }, + { + test: 'both have field and local is newer', + dates: [date1, date2] as const, + actual: 'Local Wins' as const, + }, + { + test: 'both have field and remote is newer', + dates: [date2, date1] as const, + actual: 'Remote Wins' as const, + }, +]; + +/** + * This is not used externally, but meant to check logic is consistent + */ +describe('checkWhichNetworkIsLatest()', () => { + it.each(testMatrix)( + 'should test when [$test] and the result would be: [$actual]', + ({ dates, actual }) => { + const localNetwork = createMockNetworkConfiguration({ + lastUpdatedAt: dates[0] ?? undefined, + }); + const remoteNetwork = createMockRemoteNetworkConfiguration({ + lastUpdatedAt: dates[1] ?? undefined, + }); + const result = checkWhichNetworkIsLatest(localNetwork, remoteNetwork); + expect(result).toBe(actual); + }, + ); +}); + +/** + * This is not used externally, but meant to check logic is consistent + */ +describe('getUpdatedNetworkLists()', () => { + it('should take intersecting networks and determine which needs updating', () => { + // Arrange + const localNetworks: NetworkConfiguration[] = []; + const remoteNetworks: RemoteNetworkConfiguration[] = []; + + // Test Matrix combinations + testMatrix.forEach(({ dates }, idx) => { + localNetworks.push( + createMockNetworkConfiguration({ + chainId: `0x${idx}`, + lastUpdatedAt: dates[0] ?? undefined, + }), + ); + remoteNetworks.push( + createMockRemoteNetworkConfiguration({ + chainId: `0x${idx}`, + lastUpdatedAt: dates[1] ?? undefined, + }), + ); + }); + + // Test isDeleted on remote check + localNetworks.push( + createMockNetworkConfiguration({ + chainId: '0xTestRemoteWinIsDeleted', + lastUpdatedAt: date2, + }), + ); + remoteNetworks.push( + createMockRemoteNetworkConfiguration({ + chainId: '0xTestRemoteWinIsDeleted', + lastUpdatedAt: date1, + d: true, + }), + ); + + // Test make sure these don't appear in lists + localNetworks.push( + createMockNetworkConfiguration({ chainId: '0xNotIntersecting1' }), + ); + remoteNetworks.push( + createMockRemoteNetworkConfiguration({ chainId: '0xNotIntersecting2' }), + ); + + // Act + const ds = getDataStructures(localNetworks, remoteNetworks); + const result = getUpdatedNetworkLists(ds); + const localIdsUpdated = result.localNetworksToUpdate.map((n) => n.chainId); + const localIdsRemoved = result.localNetworksToRemove.map((n) => n.chainId); + const remoteIdsUpdated = result.remoteNetworksToUpdate.map( + (n) => n.chainId, + ); + + // Assert - Test Matrix combinations were all tested + let testCount = 0; + testMatrix.forEach(({ actual }, idx) => { + const chainId = `0x${idx}` as const; + if (actual === 'Do Nothing') { + testCount += 1; + // eslint-disable-next-line jest/no-conditional-expect + expect([ + localIdsUpdated.includes(chainId), + localIdsRemoved.includes(chainId), + remoteIdsUpdated.includes(chainId), + ]).toStrictEqual([false, false, false]); + } else if (actual === 'Local Wins') { + testCount += 1; + // eslint-disable-next-line jest/no-conditional-expect + expect(remoteIdsUpdated).toContain(chainId); + } else if (actual === 'Remote Wins') { + testCount += 1; + // eslint-disable-next-line jest/no-conditional-expect + expect(localIdsUpdated).toContain(chainId); + } + }); + expect(testCount).toBe(testMatrix.length); // Matrix Combinations were all tested + + // Assert - check isDeleted item + expect(localIdsRemoved).toStrictEqual(['0xTestRemoteWinIsDeleted']); + + // Assert - check non-intersecting items are not in lists + expect([ + localIdsUpdated.includes('0xNotIntersecting1'), + localIdsRemoved.includes('0xNotIntersecting1'), + remoteIdsUpdated.includes('0xNotIntersecting1'), + ]).toStrictEqual([false, false, false]); + expect([ + localIdsUpdated.includes('0xNotIntersecting2'), + localIdsRemoved.includes('0xNotIntersecting2'), + remoteIdsUpdated.includes('0xNotIntersecting2'), + ]).toStrictEqual([false, false, false]); + }); +}); + +/** + * This is not used externally, but meant to check logic is consistent + */ +describe('getNewLocalNetworks()', () => { + it('should append original list with missing networks', () => { + const originalList = arrangeLocalNetworks(['1', '2', '3']); + const missingNetworks = arrangeLocalNetworks(['4']); + + const result = getNewLocalNetworks({ + originalList, + missingLocalNetworks: missingNetworks, + localNetworksToRemove: [], + localNetworksToUpdate: [], + }); + + expect(result).toHaveLength(4); + expect(result.map((n) => n.chainId)).toStrictEqual([ + '0x1', + '0x2', + '0x3', + '0x4', + ]); + }); + + it('should update original list if there are networks that need updating', () => { + const originalList = arrangeLocalNetworks(['1', '2', '3']); + const updatedNetwork = createMockNetworkConfiguration({ + chainId: '0x1', + name: 'Updated Name', + }); + + const result = getNewLocalNetworks({ + originalList, + missingLocalNetworks: [], + localNetworksToRemove: [], + localNetworksToUpdate: [updatedNetwork], + }); + + expect(result).toHaveLength(3); + expect(result.find((n) => n.chainId === '0x1')?.name).toBe('Updated Name'); + }); + + it('should remote a network from the original list if there are networks that need to be removed', () => { + const originalList = arrangeLocalNetworks(['1', '2', '3']); + const deletedNetwork = createMockNetworkConfiguration({ chainId: '0x1' }); + + const result = getNewLocalNetworks({ + originalList, + missingLocalNetworks: [], + localNetworksToRemove: [deletedNetwork], + localNetworksToUpdate: [], + }); + + expect(result).toHaveLength(2); + expect(result.find((n) => n.chainId === '0x1')).toBeUndefined(); + }); +}); + +describe('findNetworksToUpdate()', () => { + it('should add missing networks to remote and local', () => { + const localNetworks = arrangeLocalNetworks(['1']); + const remoteNetworks = arrangeRemoteNetworks(['2']); + + const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); + expect(result?.newLocalNetworks).toHaveLength(2); + expect(result?.newLocalNetworks.map((n) => n.chainId)).toStrictEqual([ + '0x1', + '0x2', + ]); + + // Only 1 network needs to be updated + expect(result?.remoteNetworksToUpdate).toHaveLength(1); + expect(result?.remoteNetworksToUpdate?.[0]?.chainId).toBe('0x1'); + }); + + it('should update intersecting networks', () => { + // We will test against the intersecting test matrix + const localNetworks: NetworkConfiguration[] = []; + const remoteNetworks: RemoteNetworkConfiguration[] = []; + + // Test Matrix combinations + testMatrix.forEach(({ dates }, idx) => { + localNetworks.push( + createMockNetworkConfiguration({ + chainId: `0x${idx}`, + lastUpdatedAt: dates[0] ?? undefined, + }), + ); + remoteNetworks.push( + createMockRemoteNetworkConfiguration({ + chainId: `0x${idx}`, + lastUpdatedAt: dates[1] ?? undefined, + }), + ); + }); + + const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); + const newLocalIds = result?.newLocalNetworks?.map((n) => n.chainId) ?? []; + const updateRemoteIds = + result?.remoteNetworksToUpdate?.map((n) => n.chainId) ?? []; + // Assert - Test Matrix combinations were all tested + let testCount = 0; + testMatrix.forEach(({ actual }, idx) => { + const chainId = `0x${idx}` as const; + if (actual === 'Do Nothing') { + testCount += 1; + // Combined Local Networks will include this + // Updated Remote Networks will not include this, as it is not a network that needs updating on remote + // eslint-disable-next-line jest/no-conditional-expect + expect([ + newLocalIds.includes(chainId), + updateRemoteIds.includes(chainId), + ]).toStrictEqual([true, false]); + } else if (actual === 'Local Wins') { + testCount += 1; + // Combined Local Networks will include this + // Updated Remote Networks will include this, as we need to update remote + // eslint-disable-next-line jest/no-conditional-expect + expect([ + newLocalIds.includes(chainId), + updateRemoteIds.includes(chainId), + ]).toStrictEqual([true, true]); + } else if (actual === 'Remote Wins') { + testCount += 1; + // Combined Local Networks will include this + // Updated Remote Networks will not include this, as it is not a network that needs updating on remote + // eslint-disable-next-line jest/no-conditional-expect + expect([ + newLocalIds.includes(chainId), + updateRemoteIds.includes(chainId), + ]).toStrictEqual([true, false]); + } + }); + expect(testCount).toBe(testMatrix.length); // Matrix Combinations were all tested + }); + + it('should remove deleted networks', () => { + const localNetworks = arrangeLocalNetworks(['1', '2']); + const remoteNetworks = arrangeRemoteNetworks(['1', '2']); + localNetworks[1].lastUpdatedAt = date2; + remoteNetworks[1].lastUpdatedAt = date1; + remoteNetworks[1].d = true; + + const result = findNetworksToUpdate({ localNetworks, remoteNetworks }); + // Combined Local List is updated + expect(result?.newLocalNetworks).toHaveLength(1); + expect( + result?.newLocalNetworks.find((n) => n.chainId === '0x2'), + ).toBeUndefined(); + + // Remote List does not have any networks that need updating + expect(result?.remoteNetworksToUpdate).toHaveLength(0); + }); +}); + +/** + * Test Utility - Create a list of mock local network configurations + * @param ids - list of chains to support + * @returns list of local networks + */ +function arrangeLocalNetworks(ids: string[]) { + return ids.map((id) => + createMockNetworkConfiguration({ chainId: `0x${id}` }), + ); +} + +/** + * Test Utility - Create a list of mock remote network configurations + * @param ids - list of chains to support + * @returns list of local networks + */ +function arrangeRemoteNetworks(ids: string[]) { + return ids.map((id) => + createMockRemoteNetworkConfiguration({ chainId: `0x${id}` }), + ); +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts new file mode 100644 index 0000000000..f3cd7da156 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-all.ts @@ -0,0 +1,241 @@ +import { setDifference, setIntersection } from '../utils'; +import { + toRemoteNetworkConfiguration, + type NetworkConfiguration, + type RemoteNetworkConfiguration, + toNetworkConfiguration, +} from './types'; + +type FindNetworksToUpdateProps = { + localNetworks: NetworkConfiguration[]; + remoteNetworks: RemoteNetworkConfiguration[]; +}; + +const createMap = < + Network extends NetworkConfiguration | RemoteNetworkConfiguration, +>( + networks: Network[], +): Map => { + return new Map(networks.map((n) => [n.chainId, n])); +}; + +const createKeySet = < + Network extends NetworkConfiguration | RemoteNetworkConfiguration, +>( + networks: Network[], + predicate?: (n: Network) => boolean, +): Set => { + const filteredNetworks = predicate + ? networks.filter((n) => predicate(n)) + : networks; + return new Set(filteredNetworks.map((n) => n.chainId)); +}; + +export const getDataStructures = ( + localNetworks: NetworkConfiguration[], + remoteNetworks: RemoteNetworkConfiguration[], +) => { + const localMap = createMap(localNetworks); + const remoteMap = createMap(remoteNetworks); + const localKeySet = createKeySet(localNetworks); + const remoteKeySet = createKeySet(remoteNetworks); + const existingRemoteKeySet = createKeySet(remoteNetworks, (n) => !n.d); + + return { + localMap, + remoteMap, + localKeySet, + remoteKeySet, + existingRemoteKeySet, + }; +}; + +type MatrixResult = 'Do Nothing' | 'Local Wins' | 'Remote Wins'; +export const checkWhichNetworkIsLatest = ( + localNetwork: NetworkConfiguration, + remoteNetwork: RemoteNetworkConfiguration, +): MatrixResult => { + // Neither network has updatedAt field (indicating no changes were made) + if (!localNetwork.lastUpdatedAt && !remoteNetwork.lastUpdatedAt) { + return 'Do Nothing'; + } + + // Local only has updatedAt field + if (localNetwork.lastUpdatedAt && !remoteNetwork.lastUpdatedAt) { + return 'Local Wins'; + } + + // Remote only has updatedAt field + if (!localNetwork.lastUpdatedAt && remoteNetwork.lastUpdatedAt) { + return 'Remote Wins'; + } + + // Both have updatedAt field, perform comparison + if (localNetwork.lastUpdatedAt && remoteNetwork.lastUpdatedAt) { + if (localNetwork.lastUpdatedAt === remoteNetwork.lastUpdatedAt) { + return 'Do Nothing'; + } + + return localNetwork.lastUpdatedAt > remoteNetwork.lastUpdatedAt + ? 'Local Wins' + : 'Remote Wins'; + } + + return 'Do Nothing'; +}; + +export const getMissingNetworkLists = ( + ds: ReturnType, +) => { + const { + localKeySet, + localMap, + remoteKeySet, + remoteMap, + existingRemoteKeySet, + } = ds; + + const missingLocalNetworks: NetworkConfiguration[] = []; + const missingRemoteNetworks: RemoteNetworkConfiguration[] = []; + + // Networks that are in local, but not in remote + const missingRemoteNetworkKeys = setDifference(localKeySet, remoteKeySet); + missingRemoteNetworkKeys.forEach((chain) => { + const n = localMap.get(chain); + if (n) { + missingRemoteNetworks.push(toRemoteNetworkConfiguration(n)); + } + }); + + // Networks that are in remote (not deleted), but not in local + const missingLocalNetworkKeys = setDifference( + existingRemoteKeySet, + localKeySet, + ); + missingLocalNetworkKeys.forEach((chain) => { + const n = remoteMap.get(chain); + if (n) { + missingLocalNetworks.push(toNetworkConfiguration(n)); + } + }); + + return { + missingLocalNetworks, + missingRemoteNetworks, + }; +}; + +export const getUpdatedNetworkLists = ( + ds: ReturnType, +) => { + const { localKeySet, localMap, remoteKeySet, remoteMap } = ds; + + const remoteNetworksToUpdate: RemoteNetworkConfiguration[] = []; + const localNetworksToUpdate: NetworkConfiguration[] = []; + const localNetworksToRemove: NetworkConfiguration[] = []; + + // Get networks in both, these need to be compared against + // each other to see which network to update. + const networksInBoth = setIntersection(localKeySet, remoteKeySet); + networksInBoth.forEach((chain) => { + const localNetwork = localMap.get(chain); + const remoteNetwork = remoteMap.get(chain); + if (!localNetwork || !remoteNetwork) { + return; + } + + const whichIsLatest = checkWhichNetworkIsLatest( + localNetwork, + remoteNetwork, + ); + + // Local Wins -> Need to update remote + if (whichIsLatest === 'Local Wins') { + remoteNetworksToUpdate.push(toRemoteNetworkConfiguration(localNetwork)); + } + + // Remote Wins... + if (whichIsLatest === 'Remote Wins') { + if (remoteNetwork.d) { + // ...and is deleted -> Need to remove from local list + localNetworksToRemove.push(toNetworkConfiguration(remoteNetwork)); + } else { + // ...and isn't deleted -> Need to update local list + localNetworksToUpdate.push(toNetworkConfiguration(remoteNetwork)); + } + } + }); + + return { + remoteNetworksToUpdate, + localNetworksToUpdate, + localNetworksToRemove, + }; +}; + +export const getNewLocalNetworks = (props: { + originalList: NetworkConfiguration[]; + missingLocalNetworks: NetworkConfiguration[]; + localNetworksToUpdate: NetworkConfiguration[]; + localNetworksToRemove: NetworkConfiguration[]; +}) => { + let newList = [...props.originalList]; + newList.push(...props.missingLocalNetworks); + const updateMap = createMap(props.localNetworksToUpdate); + const remoteMap = createMap(props.localNetworksToRemove); + + newList = newList + .map((n) => { + if (remoteMap.has(n.chainId)) { + return undefined; + } + + const updatedNetwork = updateMap.get(n.chainId); + if (updatedNetwork) { + return updatedNetwork; + } + + return n; + }) + .filter((n): n is NetworkConfiguration => n !== undefined); + + return newList; +}; + +export const findNetworksToUpdate = (props: FindNetworksToUpdateProps) => { + try { + const { localNetworks, remoteNetworks } = props; + + // Get Maps & Key Sets + const ds = getDataStructures(localNetworks, remoteNetworks); + + // Calc Missing Networks + const missingNetworks = getMissingNetworkLists(ds); + + // Calc Updated Networks + const updatedNetworks = getUpdatedNetworkLists(ds); + + // List of networks we need to update + const remoteNetworksToUpdate = [ + ...missingNetworks.missingRemoteNetworks, + ...updatedNetworks.remoteNetworksToUpdate, + ]; + + // List of new local networks + const newLocalNetworks = getNewLocalNetworks({ + originalList: localNetworks, + missingLocalNetworks: missingNetworks.missingLocalNetworks, + localNetworksToRemove: updatedNetworks.localNetworksToRemove, + localNetworksToUpdate: updatedNetworks.localNetworksToUpdate, + }); + + return { + remoteNetworksToUpdate, + newLocalNetworks, + }; + } catch { + // Unable to perform sync, silently fail + } + + return undefined; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts similarity index 99% rename from packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.test.ts rename to packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts index 924a88caaa..53f877f8d9 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.test.ts @@ -9,7 +9,7 @@ import { batchUpdateNetworks, deleteNetwork, updateNetwork, -} from './sync'; +} from './sync-mutations'; const storageOpts: UserStorageBaseOptions = { bearerToken: 'MOCK_TOKEN', diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts similarity index 74% rename from packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.ts rename to packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts index 0113b7b189..841a459dcd 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/sync-mutations.ts @@ -1,8 +1,6 @@ -import type { NetworkConfiguration } from '@metamask/network-controller'; - import type { UserStorageBaseOptions } from '../services'; import { batchUpsertRemoteNetworks, upsertRemoteNetwork } from './services'; -import type { RemoteNetworkConfiguration } from './types'; +import type { NetworkConfiguration, RemoteNetworkConfiguration } from './types'; export const updateNetwork = async ( network: NetworkConfiguration, @@ -18,7 +16,15 @@ export const deleteNetwork = async ( opts: UserStorageBaseOptions, ) => { // we are soft deleting, as we need to consider devices that have not yet synced - return await upsertRemoteNetwork({ v: '1', ...network, d: true }, opts); + return await upsertRemoteNetwork( + { + v: '1', + ...network, + d: true, + lastUpdatedAt: network.lastUpdatedAt ?? Date.now(), // Ensures that a deleted entry has a date field + }, + opts, + ); }; export const batchUpdateNetworks = async ( diff --git a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/types.ts b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/types.ts index 6cc5c6b37b..5eb0f9ce55 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/types.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/network-syncing/types.ts @@ -1,4 +1,9 @@ -import type { NetworkConfiguration } from '@metamask/network-controller'; +import type { NetworkConfiguration as _NetworkConfiguration } from '@metamask/network-controller'; + +// TODO - replace shim once we update NetworkController type +export type NetworkConfiguration = _NetworkConfiguration & { + lastUpdatedAt?: number; +}; export type RemoteNetworkConfiguration = NetworkConfiguration & { /** @@ -11,3 +16,19 @@ export type RemoteNetworkConfiguration = NetworkConfiguration & { */ d?: boolean; }; + +export const toRemoteNetworkConfiguration = ( + network: NetworkConfiguration, +): RemoteNetworkConfiguration => { + return { + ...network, + v: '1', + }; +}; + +export const toNetworkConfiguration = ( + network: RemoteNetworkConfiguration, +): NetworkConfiguration => { + const { v: _v, d: _d, ...originalNetwork } = network; + return originalNetwork; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/utils.test.ts new file mode 100644 index 0000000000..cecdf729f2 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/utils.test.ts @@ -0,0 +1,29 @@ +import { setDifference, setIntersection } from './utils'; + +describe('utils - setDifference()', () => { + it('should return the difference between 2 sets', () => { + const setA = new Set([1, 2, 3]); + const setB = new Set([3, 4, 5]); + + const missingInSetA = Array.from(setDifference(setB, setA)); + expect(missingInSetA).toStrictEqual([4, 5]); + + const missingInSetB = Array.from(setDifference(setA, setB)); + expect(missingInSetB).toStrictEqual([1, 2]); + }); +}); + +describe('utils - setIntersection()', () => { + it('should return values shared between 2 sets', () => { + const setA = new Set([1, 2, 3]); + const setB = new Set([3, 4, 5]); + + const inBothSets = Array.from(setIntersection(setA, setB)); + expect(inBothSets).toStrictEqual([3]); + + const inBothSetsWithParamsReversed = Array.from( + setIntersection(setB, setA), + ); + expect(inBothSetsWithParamsReversed).toStrictEqual([3]); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/utils.ts new file mode 100644 index 0000000000..e57e317b63 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/utils.ts @@ -0,0 +1,28 @@ +/** + * Returns the difference between 2 sets. + * NOTE - this is temporary until we can support Set().difference method + * @param a - First Set + * @param b - Second Set + * @returns The difference between the first and second set. + */ +export function setDifference(a: Set, b: Set): Set { + const difference = new Set(); + a.forEach((e) => !b.has(e) && difference.add(e)); + return difference; +} + +/** + * Returns the intersection between 2 sets. + * NOTE - this is temporary until we can support Set().intersection method + * @param a - First Set + * @param b - Second Set + * @returns The intersection between the first and second set. + */ +export function setIntersection( + a: Set, + b: Set, +): Set { + const intersection = new Set(); + a.forEach((e) => b.has(e) && intersection.add(e)); + return intersection; +}