nexo 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +17 -0
- data/app/controllers/nexo/element_versions_controller.rb +26 -0
- data/app/controllers/nexo/elements_controller.rb +70 -0
- data/app/controllers/nexo/folders_controller.rb +41 -0
- data/app/controllers/nexo/nexo_controller.rb +11 -0
- data/app/jobs/nexo/api_clients.rb +20 -18
- data/app/jobs/nexo/base_job.rb +5 -3
- data/app/jobs/nexo/delete_remote_resource_job.rb +23 -1
- data/app/jobs/nexo/fetch_remote_resource_job.rb +61 -0
- data/app/jobs/nexo/folder_check_status_job.rb +10 -0
- data/app/jobs/nexo/folder_destroy_job.rb +2 -1
- data/app/jobs/nexo/folder_download_job.rb +15 -0
- data/app/jobs/nexo/folder_sync_job.rb +6 -0
- data/app/jobs/nexo/synchronizable_changed_job.rb +17 -2
- data/app/jobs/nexo/update_remote_resource_job.rb +56 -26
- data/app/lib/nexo/api_client/google_auth_service.rb +9 -8
- data/app/lib/nexo/api_client/google_calendar_service.rb +162 -38
- data/app/lib/nexo/api_client/google_calendar_sync_service.rb +88 -0
- data/app/lib/nexo/element_service.rb +249 -0
- data/app/lib/nexo/errors.rb +6 -5
- data/app/lib/nexo/event_receiver.rb +4 -0
- data/app/lib/nexo/folder_service.rb +55 -28
- data/app/lib/nexo/import_remote_element_version.rb +75 -0
- data/app/lib/nexo/policy_service.rb +27 -8
- data/app/models/concerns/nexo/calendar_event.rb +39 -2
- data/app/models/concerns/nexo/synchronizable.rb +28 -9
- data/app/models/nexo/element.rb +23 -34
- data/app/models/nexo/element_version.rb +17 -2
- data/app/models/nexo/folder.rb +29 -17
- data/app/models/nexo/integration.rb +0 -3
- data/app/models/nexo/token.rb +2 -0
- data/app/views/layouts/error.html.erb +8 -0
- data/app/views/layouts/nexo.html.erb +40 -0
- data/app/views/nexo/element_versions/show.html.erb +16 -0
- data/app/views/nexo/elements/index.html.erb +40 -0
- data/app/views/nexo/elements/show.html.erb +60 -0
- data/app/views/nexo/folders/index.html.erb +22 -0
- data/app/views/nexo/folders/show.html.erb +22 -0
- data/config/environment.rb +1 -0
- data/config/routes.rb +26 -0
- data/config/spring.rb +1 -0
- data/db/migrate/20250604124821_element_sync_status.rb +13 -0
- data/db/migrate/20250612002919_google_sync_tokens.rb +5 -0
- data/db/migrate/20250623132502_folder_sync_direction.rb +8 -0
- data/db/migrate/20250718012839_synchronizable_nullable.rb +6 -0
- data/db/seeds.rb +3 -2
- data/lib/nexo/engine.rb +26 -5
- data/lib/nexo/version.rb +1 -1
- metadata +39 -4
- data/app/jobs/nexo/sync_element_job.rb +0 -93
- data/app/models/concerns/nexo/folder_policy.rb +0 -24
@@ -1,106 +1,221 @@
|
|
1
1
|
module Nexo
|
2
2
|
# Wrapper around +Google::Apis::CalendarV3+
|
3
3
|
#
|
4
|
-
#
|
4
|
+
# TODO: handle ServerError to be retried
|
5
|
+
# @raise [Google::Apis::ServerError] An error occurred on the server and the request can be retried
|
6
|
+
# @raise [Google::Apis::AuthorizationError] Authorization is required
|
7
|
+
# @raise [Google::Apis::ClientError] The request is invalid and should not be retried without modification
|
8
|
+
# possible messages:
|
5
9
|
# - duplicate: The requested identifier already exists.
|
6
10
|
# - notFound: Not Found (calendar not exists or was deleted)
|
7
11
|
# - forbidden: Forbidden (event to update was deleted)
|
12
|
+
# - conditionNotMet: Precondition Failed (etag / if-match header verification failed)
|
13
|
+
# - invalid: Invalid sequence value. The specified sequence number is below
|
14
|
+
# the current sequence number of the resource. Re-fetch the resource and
|
15
|
+
# use its sequence number on the following request.
|
8
16
|
#
|
9
17
|
# TODO! when event to update was deleted, create a new one and warn
|
10
18
|
class GoogleCalendarService < CalendarService
|
11
19
|
# Create an event in a Google Calendar
|
12
20
|
#
|
13
|
-
# @param [
|
21
|
+
# @param [Element] folder
|
14
22
|
#
|
15
|
-
|
16
|
-
|
17
|
-
validate_folder_state!(folder)
|
23
|
+
def insert(element)
|
24
|
+
validate_folder_state!(element.folder)
|
18
25
|
|
19
|
-
event = build_event(
|
20
|
-
response = client.insert_event(folder.external_identifier, event)
|
21
|
-
ApiResponse.new(payload: response.
|
26
|
+
event = build_event(element)
|
27
|
+
response = client.insert_event(element.folder.external_identifier, event)
|
28
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag, id: response.id)
|
22
29
|
end
|
23
30
|
|
24
31
|
# Update an event in a Google Calendar
|
25
32
|
def update(element)
|
26
33
|
validate_folder_state!(element.folder)
|
34
|
+
# sidebranch
|
35
|
+
# TODO!: validate uuid presence
|
36
|
+
|
37
|
+
event = build_event(element)
|
38
|
+
|
39
|
+
response = client.update_event(element.folder.external_identifier, element.uuid, event, options: ifmatch_options(element))
|
27
40
|
|
28
|
-
|
29
|
-
|
30
|
-
|
41
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag)
|
42
|
+
rescue Google::Apis::ClientError => e
|
43
|
+
if e.message.match? /conditionNotMet/
|
44
|
+
raise Errors::ConflictingRemoteElementChange, e
|
45
|
+
else
|
46
|
+
raise
|
47
|
+
end
|
31
48
|
end
|
32
49
|
|
33
50
|
# Delete an event in a Google Calendar
|
34
51
|
def remove(element)
|
35
52
|
validate_folder_state!(element.folder)
|
53
|
+
# sidebranch
|
54
|
+
# TODO!: validate uuid presence
|
36
55
|
|
37
|
-
# TODO: try with cancelled
|
38
|
-
client.delete_event(element.folder.external_identifier, element.uuid)
|
56
|
+
# TODO: try with cancelled / maybe its the same
|
57
|
+
client.delete_event(element.folder.external_identifier, element.uuid, options: ifmatch_options(element))
|
39
58
|
ApiResponse.new(payload: nil, status: :ok, etag: nil)
|
59
|
+
rescue Google::Apis::ClientError => e
|
60
|
+
if e.message.match? /conditionNotMet/
|
61
|
+
raise Errors::ConflictingRemoteElementChange, e
|
62
|
+
else
|
63
|
+
raise
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def get_event(element)
|
68
|
+
validate_folder_state!(element.folder)
|
69
|
+
# sidebranch
|
70
|
+
# TODO!: validate uuid presence
|
71
|
+
|
72
|
+
# would be nice to send If-None-Match header, but Google API doesn't seem
|
73
|
+
# to accept it
|
74
|
+
response = client.get_event(element.folder.external_identifier, element.uuid)
|
75
|
+
|
76
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag, id: response.id)
|
77
|
+
rescue Google::Apis::ClientError => e
|
78
|
+
if e.message.match? "notFound"
|
79
|
+
Nexo.logger.warn("Event not found for #{element.to_gid}")
|
80
|
+
nil
|
81
|
+
else
|
82
|
+
raise
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def get_calendar(folder)
|
87
|
+
validate_folder_state!(folder)
|
88
|
+
|
89
|
+
response = client.get_calendar(folder.external_identifier)
|
90
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag, id: response.id)
|
40
91
|
end
|
41
92
|
|
42
93
|
# Create a Google calendar
|
43
94
|
def insert_calendar(folder)
|
44
|
-
|
45
|
-
raise Errors::Error, "folder has no token"
|
46
|
-
end
|
95
|
+
validate_folder_state!(folder, verify_external_identifier_presence: false)
|
47
96
|
|
48
97
|
cal = build_calendar(folder)
|
49
98
|
response = client.insert_calendar(cal)
|
50
|
-
ApiResponse.new(payload: response.
|
99
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag, id: response.id)
|
51
100
|
end
|
52
101
|
|
53
|
-
#
|
102
|
+
# Update a Google calendar
|
54
103
|
def update_calendar(folder)
|
55
|
-
|
56
|
-
raise Errors::Error, "folder has no token"
|
57
|
-
end
|
104
|
+
validate_folder_state!(folder)
|
58
105
|
|
59
106
|
cal = build_calendar(folder)
|
60
107
|
response = client.update_calendar(folder.external_identifier, cal)
|
61
|
-
ApiResponse.new(payload: response.
|
108
|
+
ApiResponse.new(payload: response.to_h, status: :ok, etag: response.etag, id: response.id)
|
62
109
|
end
|
63
110
|
|
64
111
|
def remove_calendar(folder)
|
112
|
+
validate_folder_state!(folder)
|
113
|
+
|
65
114
|
client.delete_calendar(folder.external_identifier)
|
66
115
|
ApiResponse.new(status: :ok)
|
67
116
|
end
|
68
117
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
118
|
+
def fields_from_payload(payload)
|
119
|
+
event = validate_version!(payload)
|
120
|
+
|
121
|
+
{
|
122
|
+
date_from: parse_date(event.start),
|
123
|
+
date_to: parse_date(event.end),
|
124
|
+
time_from: parse_time(event.start),
|
125
|
+
time_to: parse_time(event.end),
|
126
|
+
summary: event.summary,
|
127
|
+
description: event.description,
|
128
|
+
|
129
|
+
# Posible status values:
|
130
|
+
# - confirmed
|
131
|
+
# - tentative
|
132
|
+
# - cancelled (deleted)
|
133
|
+
status: event.status
|
134
|
+
}
|
79
135
|
end
|
80
|
-
# :nocov:
|
81
136
|
|
82
137
|
private
|
83
138
|
|
84
|
-
def
|
139
|
+
def validate_version!(payload)
|
140
|
+
event_data = ActiveSupport::HashWithIndifferentAccess.new(payload)
|
141
|
+
event = Google::Apis::CalendarV3::Event.new(**event_data)
|
142
|
+
|
143
|
+
validate_datetime!(event.start)
|
144
|
+
validate_datetime!(event.end)
|
145
|
+
|
146
|
+
event
|
147
|
+
end
|
148
|
+
|
149
|
+
def parse_date(datetime)
|
150
|
+
if datetime["date"].present?
|
151
|
+
Date.parse(datetime["date"])
|
152
|
+
else
|
153
|
+
Date.parse(datetime["date_time"])
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def parse_time(datetime)
|
158
|
+
if datetime["date"].present?
|
159
|
+
nil
|
160
|
+
else
|
161
|
+
Time.parse(datetime["date_time"])
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def validate_datetime!(datetime)
|
166
|
+
unless datetime.is_a?(Hash) && (datetime["date"].present? || datetime["date_time"].present?)
|
167
|
+
# :nocov: borderline
|
168
|
+
raise "invalid datetime"
|
169
|
+
# :nocov:
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def validate_folder_state!(folder, verify_external_identifier_presence: true)
|
85
174
|
unless folder.integration.token?
|
86
175
|
raise Errors::Error, "folder has no token"
|
87
176
|
end
|
88
177
|
|
89
|
-
if folder.external_identifier.blank?
|
178
|
+
if verify_external_identifier_presence && folder.external_identifier.blank?
|
90
179
|
raise Errors::InvalidFolderState, folder
|
91
180
|
end
|
92
181
|
end
|
93
182
|
|
94
|
-
def build_event(
|
183
|
+
def build_event(element)
|
184
|
+
calendar_event = element.synchronizable
|
95
185
|
estart = build_event_date_time(calendar_event.datetime_from)
|
96
186
|
eend = build_event_date_time(calendar_event.datetime_to)
|
97
187
|
|
98
|
-
|
188
|
+
# TODO: sequence is managed by both google and nexo
|
189
|
+
# google accepts the sent sequence only if its valid (>= current_sequence)
|
190
|
+
# when we receive a remote change with updated sequence, we should
|
191
|
+
# update the secuence accordingly. and if we receive a remote change without a
|
192
|
+
# secuence increment, then we could:
|
193
|
+
# 1. not update the local secuence. but maybe thats not viable
|
194
|
+
# 2. increment the local secuence and update the remote
|
195
|
+
# 3. increment the local secuence and not update the remote
|
196
|
+
# 4. <current> ignore the sequence from google
|
197
|
+
|
198
|
+
from = element.last_remote_version
|
199
|
+
|
200
|
+
base_event =
|
201
|
+
if from.present?
|
202
|
+
event_data = ActiveSupport::HashWithIndifferentAccess.new(from.payload)
|
203
|
+
Google::Apis::CalendarV3::Event.new(**event_data)
|
204
|
+
else
|
205
|
+
Google::Apis::CalendarV3::Event.new
|
206
|
+
end
|
207
|
+
|
208
|
+
base_event.update!(
|
99
209
|
start: estart,
|
100
210
|
end: eend,
|
101
211
|
summary: calendar_event.summary,
|
102
|
-
description: calendar_event.description
|
212
|
+
description: calendar_event.description,
|
213
|
+
transparency: calendar_event.transparency,
|
214
|
+
status: calendar_event.nce_status,
|
215
|
+
# sequence: calendar_event.sequence
|
103
216
|
)
|
217
|
+
|
218
|
+
base_event
|
104
219
|
end
|
105
220
|
|
106
221
|
def build_calendar(folder)
|
@@ -128,5 +243,14 @@ module Nexo
|
|
128
243
|
end
|
129
244
|
# :nocov:
|
130
245
|
end
|
246
|
+
|
247
|
+
def ifmatch_options(element)
|
248
|
+
ifmatch = element.etag
|
249
|
+
Nexo.logger.debug { "ifmatch: #{ifmatch}" }
|
250
|
+
|
251
|
+
raise Errors::Error, "an etag is required to perform the request" if ifmatch.blank?
|
252
|
+
|
253
|
+
Google::Apis::RequestOptions.new(header: { "If-Match" => ifmatch })
|
254
|
+
end
|
131
255
|
end
|
132
256
|
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Nexo
|
2
|
+
class GoogleCalendarSyncService < GoogleCalendarService
|
3
|
+
def full_sync!(folder)
|
4
|
+
Nexo.logger.info("performing full sync")
|
5
|
+
sync!(folder)
|
6
|
+
end
|
7
|
+
|
8
|
+
def incremental_sync!(folder)
|
9
|
+
raise Nexo::Errors::Error, "no sync token" if folder.google_next_sync_token.blank?
|
10
|
+
|
11
|
+
Nexo.logger.info("performing incremental sync")
|
12
|
+
|
13
|
+
sync!(folder, sync_token: folder.google_next_sync_token)
|
14
|
+
rescue Google::Apis::ClientError => e
|
15
|
+
if e.message.match /fullSyncRequired/
|
16
|
+
Nexo.logger.warn "Full sync required: #{e}"
|
17
|
+
full_sync!(folder)
|
18
|
+
else
|
19
|
+
raise
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def full_or_incremental_sync!(folder)
|
24
|
+
if folder.google_next_sync_token.present?
|
25
|
+
incremental_sync!(folder)
|
26
|
+
else
|
27
|
+
full_sync!(folder)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# @raise [Google::Apis::ClientError] The request is invalid and should not be retried without modification
|
34
|
+
# TODO: handle 410 (Gone) to discard the sync_token and schedule a full_sync
|
35
|
+
# (fullSyncRequired: Sync token is no longer valid, a full sync is required.
|
36
|
+
def sync!(folder, page_token: nil, sync_token: nil)
|
37
|
+
page_token = nil
|
38
|
+
loop do
|
39
|
+
if page_token.present?
|
40
|
+
Nexo.logger.debug("Calling list_events with page_token")
|
41
|
+
elsif sync_token.present?
|
42
|
+
Nexo.logger.debug("Calling list_events with sync_token")
|
43
|
+
# raise Google::Apis::ClientError, "fullSyncRequired: Sync token is no longer valid..."
|
44
|
+
else
|
45
|
+
Nexo.logger.debug("Calling list_events without sync_token")
|
46
|
+
end
|
47
|
+
|
48
|
+
events = client.list_events(folder.external_identifier, page_token:, sync_token:)
|
49
|
+
Nexo.logger.debug("retrieved a page with #{events.items.length} items")
|
50
|
+
sync_token = nil
|
51
|
+
|
52
|
+
events.items.each do |event|
|
53
|
+
response = ApiResponse.new(payload: event.to_h, etag: event.etag, id: event.id)
|
54
|
+
|
55
|
+
element = Element.kept.where(uuid: response.id).first
|
56
|
+
|
57
|
+
if element.present?
|
58
|
+
Nexo.logger.debug("Element found for event")
|
59
|
+
|
60
|
+
FetchRemoteResourceJob.new.handle_response(element, response)
|
61
|
+
elsif event.status == "cancelled"
|
62
|
+
Nexo.logger.debug("Skipping cancelled event")
|
63
|
+
else
|
64
|
+
element = ElementService.new.create_element_for_remote_resource!(folder, response)
|
65
|
+
|
66
|
+
# TODO!: rename handle_response
|
67
|
+
FetchRemoteResourceJob.new.handle_response(element, response)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
page_token = events.next_page_token
|
72
|
+
if page_token
|
73
|
+
Nexo.logger.debug("Page token present. Fetching next page.")
|
74
|
+
else
|
75
|
+
sync_token = events.next_sync_token
|
76
|
+
if sync_token.present?
|
77
|
+
Nexo.logger.debug("Sync token present. Saving it to Folder")
|
78
|
+
folder.update!(google_next_sync_token: sync_token)
|
79
|
+
else
|
80
|
+
Nexo.logger.error("Sync token should have been present!")
|
81
|
+
end
|
82
|
+
|
83
|
+
break
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,249 @@
|
|
1
|
+
module Nexo
|
2
|
+
class ElementService
|
3
|
+
attr_accessor :element, :element_version
|
4
|
+
|
5
|
+
def initialize(element_version: nil, element: nil)
|
6
|
+
@element_version = element_version
|
7
|
+
@element = element || element_version&.element
|
8
|
+
end
|
9
|
+
|
10
|
+
def create_element_version!(attributes)
|
11
|
+
element.with_lock do
|
12
|
+
_create_element_version!(attributes)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def update_element_version!(attributes)
|
17
|
+
element.with_lock do
|
18
|
+
element_version.update!(attributes)
|
19
|
+
_update_ne_status!
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def update_element!(attributes)
|
24
|
+
element.update!(attributes)
|
25
|
+
end
|
26
|
+
|
27
|
+
def discard!
|
28
|
+
element.update!(discarded_at: Time.current)
|
29
|
+
_update_ne_status!
|
30
|
+
end
|
31
|
+
|
32
|
+
# The reason of `flagged_for_removal` is that there is a time gap between
|
33
|
+
# the the user action of removing the element and the actual API call that
|
34
|
+
# deletes the remote element. At some point is necesary to know if the
|
35
|
+
# element is being deleted, like when fetching a remote version previous to
|
36
|
+
# the actual delete API call.
|
37
|
+
def flag_for_removal!(removal_reason)
|
38
|
+
Nexo.logger.debug("Flagging an element for removal")
|
39
|
+
|
40
|
+
element.update!(flagged_for_removal: true, removal_reason:)
|
41
|
+
_update_ne_status!
|
42
|
+
end
|
43
|
+
|
44
|
+
# @raise ActiveRecord::RecordNotUnique
|
45
|
+
def update_synchronizable!
|
46
|
+
Nexo.logger.debug("update_synchronizable!")
|
47
|
+
|
48
|
+
element.with_lock do
|
49
|
+
service = ServiceBuilder.instance.build_protocol_service(element_version.element.folder)
|
50
|
+
fields = service.fields_from_payload(element_version.payload)
|
51
|
+
|
52
|
+
# and set the Synchronizable fields according to the Folder#nexo_protocol
|
53
|
+
synchronizable = element_version.element.synchronizable
|
54
|
+
if element.flagged_for_removal?
|
55
|
+
Nexo.logger.info("Element flagged for removal")
|
56
|
+
ElementService.new(element_version:).update_element_version!(
|
57
|
+
nev_status: :ignored_by_deletion
|
58
|
+
)
|
59
|
+
elsif synchronizable.present?
|
60
|
+
synchronizable.update_from_fields!(fields)
|
61
|
+
|
62
|
+
# synchronizable could have been destroyed
|
63
|
+
if synchronizable.persisted?
|
64
|
+
# si esto se ejecuta en paralelo con SynchronizableChangedJob? (para otro
|
65
|
+
# element del mismo synchronizable) puede haber race conditions
|
66
|
+
synchronizable.increment_sequence!
|
67
|
+
synchronizable.reload
|
68
|
+
|
69
|
+
ElementService.new(element_version:).update_element_version!(
|
70
|
+
sequence: synchronizable.sequence,
|
71
|
+
nev_status: :synced
|
72
|
+
)
|
73
|
+
|
74
|
+
SynchronizableChangedJob.perform_later(synchronizable, excluded_folders: [ element.folder.id ])
|
75
|
+
else
|
76
|
+
Nexo.logger.debug("Synchronizable destroyed. Removing other elements")
|
77
|
+
ElementService.new(element_version:).update_element_version!(
|
78
|
+
sequence: nil,
|
79
|
+
nev_status: :synced
|
80
|
+
)
|
81
|
+
|
82
|
+
FolderService.new.destroy_elements(
|
83
|
+
synchronizable, :synchronizable_destroyed, exclude_elements: [ element.id ])
|
84
|
+
end
|
85
|
+
else
|
86
|
+
Nexo.logger.info("Synchronizable not found")
|
87
|
+
policies = PolicyService.instance.policies_for(element.folder)
|
88
|
+
importer_rule = policies.select { |p| p.import_payload?(element_version.payload) }.first
|
89
|
+
if importer_rule.present?
|
90
|
+
Nexo.logger.debug("Found an importer rule")
|
91
|
+
synchronizable = importer_rule.create_synchronizable_from_payload!(element_version.payload)
|
92
|
+
ElementService.new(element:).update_element!(synchronizable:)
|
93
|
+
ElementService.new(element_version:).update_element_version!(
|
94
|
+
nev_status: :synced,
|
95
|
+
sequence: synchronizable.sequence
|
96
|
+
)
|
97
|
+
else
|
98
|
+
Nexo.logger.info("No importer rule found for event. Skipping")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def resolve_conflict!
|
105
|
+
unless element.conflicted?
|
106
|
+
raise "element not conflicted"
|
107
|
+
end
|
108
|
+
|
109
|
+
# both lock on Element and start a transaction
|
110
|
+
element.with_lock do
|
111
|
+
external_change = element.element_versions.where(origin: :external, nev_status: :pending_sync).order(:etag).last
|
112
|
+
local_change = element.element_versions.where(origin: :internal, nev_status: :pending_sync).order(:sequence).last
|
113
|
+
last_synced = element.element_versions.where(nev_status: :synced).order(:sequence).last
|
114
|
+
|
115
|
+
if local_change.sequence < last_synced.sequence
|
116
|
+
raise "there a newer synced sequence"
|
117
|
+
end
|
118
|
+
|
119
|
+
if external_change.etag < last_synced.etag
|
120
|
+
raise "there a newer synced etag"
|
121
|
+
end
|
122
|
+
|
123
|
+
Nexo.logger.debug { "resolving conflict" }
|
124
|
+
remote_update = Time.zone.parse(external_change.payload["updated"])
|
125
|
+
local_update = element.synchronizable.updated_at
|
126
|
+
Nexo.logger.debug { "Remote updated at: #{remote_update}. Local updated at #{local_update}" }
|
127
|
+
if remote_update > local_update
|
128
|
+
Nexo.logger.debug { "Remote wins, ignoring local change" }
|
129
|
+
_update_status_on_conflict_with_winner!(external_change)
|
130
|
+
ImportRemoteElementVersion.new.perform(external_change)
|
131
|
+
else
|
132
|
+
Nexo.logger.debug { "Local wins, discarding remote changes" }
|
133
|
+
_update_status_on_conflict_with_winner!(local_change)
|
134
|
+
UpdateRemoteResourceJob.perform_later(local_change)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def update_ne_status!
|
140
|
+
element.with_lock do
|
141
|
+
_update_ne_status!
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def create_internal_version_if_none!
|
146
|
+
# NOTE: though synchronizable it's not locked and could change in between
|
147
|
+
# the block, this shouldn't run concurrently because its called from
|
148
|
+
# SynchronizableChangedJob that its limited to one perform at a time per
|
149
|
+
# synchronizable
|
150
|
+
element.with_lock do
|
151
|
+
if element.element_versions.where(sequence: element.synchronizable.sequence).any?
|
152
|
+
Nexo.logger.debug { "There is a version for current sequence, nothing to do" }
|
153
|
+
return
|
154
|
+
end
|
155
|
+
|
156
|
+
_create_internal_version!
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def create_element_for_remote_resource!(folder, response)
|
161
|
+
Element.create!(
|
162
|
+
folder:,
|
163
|
+
uuid: response.id,
|
164
|
+
ne_status: :pending_external_sync
|
165
|
+
)
|
166
|
+
end
|
167
|
+
|
168
|
+
def create_element_for!(folder, synchronizable)
|
169
|
+
Element.transaction do
|
170
|
+
@element = Element.create!(
|
171
|
+
synchronizable:,
|
172
|
+
folder:,
|
173
|
+
ne_status: :pending_local_sync
|
174
|
+
)
|
175
|
+
Nexo.logger.debug { "Element created" }
|
176
|
+
|
177
|
+
_create_internal_version!
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
private
|
182
|
+
|
183
|
+
def _update_status_on_conflict_with_winner!(win_version)
|
184
|
+
element.element_versions.where(nev_status: :pending_sync)
|
185
|
+
.where.not(id: win_version.id)
|
186
|
+
.update_all(nev_status: :ignored_in_conflict)
|
187
|
+
|
188
|
+
_update_ne_status!
|
189
|
+
end
|
190
|
+
|
191
|
+
def _create_internal_version!
|
192
|
+
nev_status =
|
193
|
+
element.folder.sync_internal_changes? ? :pending_sync : :ignored_by_sync_direction
|
194
|
+
|
195
|
+
_create_element_version!(
|
196
|
+
origin: :internal,
|
197
|
+
sequence: element.synchronizable.sequence,
|
198
|
+
nev_status:
|
199
|
+
).tap do |element_version|
|
200
|
+
Nexo.logger.debug("ElementVersion created")
|
201
|
+
|
202
|
+
if element.pending_local_sync?
|
203
|
+
Nexo.logger.debug("Enqueuing UpdateRemoteResourceJob")
|
204
|
+
|
205
|
+
UpdateRemoteResourceJob.perform_later(element_version)
|
206
|
+
elsif element.conflicted?
|
207
|
+
Nexo.logger.info("Element conflicted, so not enqueuing UpdateRemoteResourceJob")
|
208
|
+
elsif element.synced?
|
209
|
+
Nexo.logger.info("Element's ne_status is: #{element.ne_status}. No need to push any changes.")
|
210
|
+
else
|
211
|
+
# :nocov: borderline
|
212
|
+
Nexo.logger.info("Element's ne_status is: #{element.ne_status}. That's weird.")
|
213
|
+
# :nocov:
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def _create_element_version!(attributes)
|
219
|
+
ElementVersion.create!(attributes.merge(element:)).tap do
|
220
|
+
_update_ne_status!
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def _update_ne_status!
|
225
|
+
external_change =
|
226
|
+
element.folder.sync_external_changes? &&
|
227
|
+
element.element_versions.where(origin: :external, nev_status: :pending_sync).any?
|
228
|
+
|
229
|
+
local_change =
|
230
|
+
element.folder.sync_internal_changes? &&
|
231
|
+
element.element_versions.where(origin: :internal, nev_status: :pending_sync).any?
|
232
|
+
|
233
|
+
element.ne_status =
|
234
|
+
if external_change && local_change
|
235
|
+
:conflicted
|
236
|
+
elsif external_change
|
237
|
+
:pending_external_sync
|
238
|
+
elsif local_change
|
239
|
+
:pending_local_sync
|
240
|
+
elsif element.flagged_for_removal? && element.discarded_at.nil?
|
241
|
+
:pending_remote_delete
|
242
|
+
else
|
243
|
+
:synced
|
244
|
+
end
|
245
|
+
|
246
|
+
element.save!
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
data/app/lib/nexo/errors.rb
CHANGED
@@ -2,13 +2,16 @@ module Nexo
|
|
2
2
|
class Errors
|
3
3
|
class Error < StandardError; end
|
4
4
|
|
5
|
-
class
|
6
|
-
class
|
5
|
+
class SynchronizableInvalid < Error; end
|
6
|
+
class ConflictingRemoteElementChange < Error; end
|
7
|
+
class UpdateRemoteVersionFailed < Error; end
|
8
|
+
|
9
|
+
# From here on, classes are subject to review
|
10
|
+
# A lot of them are never rescued explicitly
|
7
11
|
class ElementAlreadySynced < Error; end
|
8
12
|
class MoreThanOneElementInFolderForSynchronizable < Error; end
|
9
13
|
class InvalidFolderState < Error; end
|
10
14
|
class FolderDiscarded < Error; end
|
11
|
-
class SynchronizableDiscarded < Error; end
|
12
15
|
|
13
16
|
# on ControllerHelper
|
14
17
|
class InvalidParamsError < Error; end
|
@@ -19,8 +22,6 @@ module Nexo
|
|
19
22
|
# on EventReceiver
|
20
23
|
class InvalidSynchronizableState < Error; end
|
21
24
|
|
22
|
-
# on SyncElementJob
|
23
|
-
class SyncElementJobError < Errors::Error; end
|
24
25
|
class SynchronizableConflicted < Error; end
|
25
26
|
class ElementDiscarded < Error; end
|
26
27
|
class SynchronizableNotFound < Error; end
|
@@ -21,6 +21,8 @@ module Nexo
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def folder_changed(folder)
|
24
|
+
return unless folder.sync_internal_changes?
|
25
|
+
|
24
26
|
if folder.discarded?
|
25
27
|
raise "folder discarded"
|
26
28
|
end
|
@@ -28,6 +30,8 @@ module Nexo
|
|
28
30
|
end
|
29
31
|
|
30
32
|
def folder_discarded(folder)
|
33
|
+
return unless folder.sync_internal_changes?
|
34
|
+
|
31
35
|
FolderDestroyJob.perform_later(folder)
|
32
36
|
end
|
33
37
|
|