@anfenn/zync 0.3.4 → 0.4.1

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.
package/dist/index.js CHANGED
@@ -23,9 +23,40 @@ function newLogger(base, min) {
23
23
 
24
24
  // src/helpers.ts
25
25
  var SYNC_FIELDS = ["_localId", "updated_at", "deleted"];
26
- function nextLocalId() {
26
+ function createLocalId() {
27
27
  return crypto.randomUUID();
28
28
  }
29
+ function sleep(ms) {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+ function changeKeysTo(input, toIdKey, toUpdatedAtKey, toDeletedKey) {
33
+ if (!input) return input;
34
+ const isArray = Array.isArray(input);
35
+ const result = (isArray ? input : [input]).map((item) => {
36
+ const { id, updated_at, deleted, ...rest } = item;
37
+ return {
38
+ [toIdKey]: id,
39
+ [toUpdatedAtKey]: updated_at,
40
+ [toDeletedKey]: deleted,
41
+ ...rest
42
+ };
43
+ });
44
+ return isArray ? result : result[0];
45
+ }
46
+ function changeKeysFrom(input, fromIdKey, fromUpdatedAtKey, fromDeletedKey) {
47
+ if (!input) return input;
48
+ const isArray = Array.isArray(input);
49
+ const result = (isArray ? input : [input]).map((item) => {
50
+ const { [fromIdKey]: id, [fromUpdatedAtKey]: updated_at, [fromDeletedKey]: deleted, ...rest } = item;
51
+ return {
52
+ id,
53
+ updated_at,
54
+ deleted,
55
+ ...rest
56
+ };
57
+ });
58
+ return isArray ? result : result[0];
59
+ }
29
60
  function orderFor(a) {
30
61
  switch (a) {
31
62
  case "create" /* Create */:
@@ -42,8 +73,8 @@ function omitSyncFields(item) {
42
73
  return result;
43
74
  }
44
75
  function samePendingVersion(get, stateKey, localId, version) {
45
- const q = get().syncState.pendingChanges || [];
46
- const curChange = q.find((p) => p.localId === localId && p.stateKey === stateKey);
76
+ const pending = get().syncState.pendingChanges || [];
77
+ const curChange = pending.find((p) => p.localId === localId && p.stateKey === stateKey);
47
78
  return curChange?.version === version;
48
79
  }
49
80
  function removeFromPendingChanges(set, localId, stateKey) {
@@ -77,7 +108,7 @@ function tryAddToPendingChanges(pendingChanges, stateKey, changes) {
77
108
  queueItem.changes = { ...queueItem.changes, ...omittedItem };
78
109
  }
79
110
  } else if (action === "remove" /* Remove */ || hasChanges) {
80
- pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem });
111
+ pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem, before: omitSyncFields(change.currentItem) });
81
112
  }
82
113
  }
83
114
  }
@@ -89,6 +120,30 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
89
120
  if (id) change.id = id;
90
121
  }
91
122
  }
123
+ function setPendingChangeBefore(get, stateKey, localId, before) {
124
+ const pendingChanges = get().syncState.pendingChanges || [];
125
+ const change = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
126
+ if (change) {
127
+ change.before = { ...change.before, ...before };
128
+ }
129
+ }
130
+ function tryUpdateConflicts(pendingChanges, conflicts) {
131
+ if (!conflicts) return conflicts;
132
+ const newConflicts = { ...conflicts };
133
+ for (const change of pendingChanges) {
134
+ const conflict = newConflicts[change.localId];
135
+ if (conflict && change.changes) {
136
+ const newFields = conflict.fields.map((f) => {
137
+ if (f.key in change.changes) {
138
+ return { ...f, localValue: change.changes[f.key] };
139
+ }
140
+ return f;
141
+ });
142
+ newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
143
+ }
144
+ }
145
+ return newConflicts;
146
+ }
92
147
  function findApi(stateKey, syncApi) {
93
148
  const api = syncApi[stateKey];
94
149
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -130,6 +185,50 @@ function findChanges(current, updated) {
130
185
  }
131
186
  return changesMap;
132
187
  }
188
+ function hasKeysOrUndefined(obj) {
189
+ return Object.keys(obj).length === 0 ? void 0 : obj;
190
+ }
191
+ function hasConflicts(get, localId) {
192
+ const state = get();
193
+ if (state.syncState.conflicts) {
194
+ return !!state.syncState.conflicts[localId];
195
+ }
196
+ return false;
197
+ }
198
+ function resolveConflict(set, localId, keepLocalFields) {
199
+ set((state) => {
200
+ const syncState = state.syncState || {};
201
+ const conflicts = syncState.conflicts || {};
202
+ const conflict = conflicts[localId];
203
+ if (conflict) {
204
+ const items = state[conflict.stateKey];
205
+ const item = items.find((i) => i._localId === localId);
206
+ if (!item) {
207
+ return state;
208
+ }
209
+ const resolved = { ...item };
210
+ let pendingChanges = [...syncState.pendingChanges];
211
+ if (!keepLocalFields) {
212
+ for (const field of conflict.fields) {
213
+ resolved[field.key] = field.remoteValue;
214
+ }
215
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
216
+ }
217
+ const nextItems = items.map((i) => i._localId === localId ? resolved : i);
218
+ const nextConflicts = { ...conflicts };
219
+ delete nextConflicts[localId];
220
+ return {
221
+ [conflict.stateKey]: nextItems,
222
+ syncState: {
223
+ ...syncState,
224
+ pendingChanges,
225
+ conflicts: hasKeysOrUndefined(nextConflicts)
226
+ }
227
+ };
228
+ }
229
+ return state;
230
+ });
231
+ }
133
232
 
134
233
  // src/pull.ts
135
234
  async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
@@ -140,7 +239,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
140
239
  if (!serverData?.length) return;
141
240
  let newest = lastPulledAt;
142
241
  set((state) => {
143
- const pendingChanges = state.syncState.pendingChanges || [];
242
+ let pendingChanges = [...state.syncState.pendingChanges];
243
+ const conflicts = { ...state.syncState.conflicts };
144
244
  const localItems = state[stateKey] || [];
145
245
  let nextItems = [...localItems];
146
246
  const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
@@ -164,43 +264,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
164
264
  if (localItem) {
165
265
  const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
166
266
  if (pendingChange) {
267
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
167
268
  switch (conflictResolutionStrategy) {
168
- case "local-wins":
169
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
269
+ case "client-wins":
170
270
  break;
171
- case "remote-wins": {
172
- const merged = {
173
- ...remote,
174
- _localId: localItem._localId
175
- };
271
+ case "server-wins": {
272
+ const merged = { ...remote, _localId: localItem._localId };
176
273
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
177
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
274
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
178
275
  break;
179
276
  }
180
- // case 'try-shallow-merge':
181
- // // Try and merge all fields, fail if not possible due to conflicts
182
- // // throw new ConflictError('Details...');
183
- // break;
184
- // case 'custom':
185
- // // Hook to allow custom userland logic
186
- // // const error = onConflict(localItem, remote, stateKey, pending);
187
- // // logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id} error=${error}`);
188
- // // if (error) throw new ConflictError(error);
189
- // break;
190
- default:
191
- logger.error(`[zync] pull:conflict-strategy:unknown stateKey=${stateKey} id=${remote.id} strategy=${conflictResolutionStrategy}`);
277
+ case "try-shallow-merge": {
278
+ const changes = pendingChange.changes || {};
279
+ const before = pendingChange.before || {};
280
+ const fields = Object.entries(changes).filter(([k, localValue]) => k in before && k in remote && before[k] !== remote[k] && localValue !== remote[k]).map(([key, localValue]) => ({ key, localValue, remoteValue: remote[key] }));
281
+ if (fields.length > 0) {
282
+ logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
283
+ conflicts[localItem._localId] = { stateKey, fields };
284
+ } else {
285
+ const localChangedKeys = Object.keys(changes);
286
+ const preservedLocal = { _localId: localItem._localId };
287
+ for (const k of localChangedKeys) {
288
+ if (k in localItem) preservedLocal[k] = localItem[k];
289
+ }
290
+ const merged = { ...remote, ...preservedLocal };
291
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
292
+ delete conflicts[localItem._localId];
293
+ }
192
294
  break;
295
+ }
193
296
  }
194
297
  } else {
195
- const merged = {
196
- ...localItem,
197
- ...remote
198
- };
298
+ const merged = { ...localItem, ...remote };
199
299
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
200
300
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
201
301
  }
202
302
  } else {
203
- nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
303
+ nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
204
304
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
205
305
  }
206
306
  }
@@ -208,6 +308,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
208
308
  [stateKey]: nextItems,
209
309
  syncState: {
210
310
  ...state.syncState || {},
311
+ pendingChanges,
312
+ conflicts: hasKeysOrUndefined(conflicts),
211
313
  lastPulled: {
212
314
  ...state.syncState.lastPulled || {},
213
315
  [stateKey]: newest.toISOString()
@@ -221,7 +323,6 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
221
323
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
222
324
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
223
325
  const { action, stateKey, localId, id, version, changes } = change;
224
- const changesClone = { ...changes };
225
326
  switch (action) {
226
327
  case "remove" /* Remove */:
227
328
  if (!id) {
@@ -234,11 +335,17 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
234
335
  removeFromPendingChanges(set, localId, stateKey);
235
336
  break;
236
337
  case "update" /* Update */: {
237
- const exists = await api.update(id, changesClone);
338
+ if (hasConflicts(get, change.localId)) {
339
+ logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
340
+ return;
341
+ }
342
+ const exists = await api.update(id, changes);
238
343
  if (exists) {
239
344
  logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
240
345
  if (samePendingVersion(get, stateKey, localId, version)) {
241
346
  removeFromPendingChanges(set, localId, stateKey);
347
+ } else {
348
+ setPendingChangeBefore(get, stateKey, localId, changes);
242
349
  }
243
350
  return;
244
351
  } else {
@@ -260,7 +367,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
260
367
  case "insert-remote-record": {
261
368
  const newItem = {
262
369
  ...item,
263
- _localId: nextLocalId(),
370
+ _localId: createLocalId(),
264
371
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
265
372
  };
266
373
  setAndQueueToSync((s) => ({
@@ -282,7 +389,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
282
389
  break;
283
390
  }
284
391
  case "create" /* Create */: {
285
- const result = await api.add(changesClone);
392
+ const result = await api.add(changes);
286
393
  if (result) {
287
394
  logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
288
395
  set((s) => ({
@@ -339,7 +446,7 @@ async function startFirstLoad(set, syncApi, logger) {
339
446
  } else {
340
447
  next.push({
341
448
  ...remote,
342
- _localId: nextLocalId()
449
+ _localId: createLocalId()
343
450
  });
344
451
  }
345
452
  }
@@ -453,17 +560,17 @@ function createIndexedDBStorage(options) {
453
560
  }
454
561
 
455
562
  // src/index.ts
456
- var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
457
- SyncAction2["Create"] = "create";
458
- SyncAction2["Update"] = "update";
459
- SyncAction2["Remove"] = "remove";
460
- return SyncAction2;
563
+ var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
564
+ SyncAction3["Create"] = "create";
565
+ SyncAction3["Update"] = "update";
566
+ SyncAction3["Remove"] = "remove";
567
+ return SyncAction3;
461
568
  })(SyncAction || {});
462
- var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
569
+ var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
463
570
  var DEFAULT_LOGGER = console;
464
571
  var DEFAULT_MIN_LOG_LEVEL = "debug";
465
572
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
466
- var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
573
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
467
574
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
468
575
  const store = create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
469
576
  return new Promise((resolve) => {
@@ -500,7 +607,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
500
607
  syncState: {
501
608
  firstLoadDone: syncState.firstLoadDone,
502
609
  pendingChanges: syncState.pendingChanges,
503
- lastPulled: syncState.lastPulled
610
+ lastPulled: syncState.lastPulled,
611
+ conflicts: syncState.conflicts
504
612
  }
505
613
  };
506
614
  },
@@ -509,7 +617,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
509
617
  return {
510
618
  ...state,
511
619
  syncState: {
512
- ...state.syncState,
620
+ ...state.syncState || {},
513
621
  status: "idle"
514
622
  // this confirms 'hydrating' is done
515
623
  }
@@ -517,13 +625,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
517
625
  }
518
626
  };
519
627
  const creator = (set, get, storeApi) => {
520
- let syncIntervalId;
628
+ let syncTimerStarted = false;
521
629
  async function syncOnce() {
522
- const state = get();
523
- if (!state.syncState.enabled || state.syncState.status !== "idle") return;
524
- set((state2) => ({
630
+ if (get().syncState.status !== "idle") return;
631
+ set((state) => ({
525
632
  syncState: {
526
- ...state2.syncState || {},
633
+ ...state.syncState || {},
527
634
  status: "syncing"
528
635
  }
529
636
  }));
@@ -558,24 +665,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
558
665
  logger.error(`[zync] push:error change=${change}`, err);
559
666
  }
560
667
  }
561
- set((state2) => ({
668
+ set((state) => ({
562
669
  syncState: {
563
- ...state2.syncState || {},
670
+ ...state.syncState || {},
564
671
  status: "idle",
565
672
  error: firstSyncError
566
673
  }
567
674
  }));
568
- if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
569
- await syncOnce();
570
- }
571
- }
572
- function setAndSyncOnce(partial) {
573
- if (typeof partial === "function") {
574
- set((state) => ({ ...partial(state) }));
575
- } else {
576
- set(partial);
577
- }
578
- syncOnce();
579
675
  }
580
676
  function setAndQueueToSync(partial) {
581
677
  if (typeof partial === "function") {
@@ -583,7 +679,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
583
679
  } else {
584
680
  set((state) => newSyncState(state, partial));
585
681
  }
586
- syncOnce();
587
682
  }
588
683
  function newSyncState(state, partial) {
589
684
  const pendingChanges = state.syncState.pendingChanges || [];
@@ -593,11 +688,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
593
688
  const changes = findChanges(current, updated);
594
689
  tryAddToPendingChanges(pendingChanges, stateKey, changes);
595
690
  });
691
+ const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
596
692
  return {
597
693
  ...partial,
598
694
  syncState: {
599
695
  ...state.syncState || {},
600
- pendingChanges
696
+ pendingChanges,
697
+ conflicts
601
698
  }
602
699
  };
603
700
  }
@@ -605,18 +702,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
605
702
  set((state) => ({
606
703
  syncState: {
607
704
  ...state.syncState || {},
608
- enabled
705
+ status: enabled ? "idle" : "disabled"
609
706
  }
610
707
  }));
611
- enableSyncTimer(enabled);
708
+ startSyncTimer(enabled);
612
709
  addVisibilityChangeListener(enabled);
613
710
  }
614
- function enableSyncTimer(enabled) {
615
- clearInterval(syncIntervalId);
616
- syncIntervalId = void 0;
617
- if (enabled) {
618
- syncIntervalId = setInterval(syncOnce, syncInterval);
619
- syncOnce();
711
+ function startSyncTimer(start) {
712
+ if (start) {
713
+ tryStart();
714
+ } else {
715
+ syncTimerStarted = false;
716
+ }
717
+ }
718
+ async function tryStart() {
719
+ if (syncTimerStarted) return;
720
+ syncTimerStarted = true;
721
+ while (true) {
722
+ if (!syncTimerStarted) break;
723
+ await syncOnce();
724
+ await sleep(syncInterval);
620
725
  }
621
726
  }
622
727
  function addVisibilityChangeListener(add) {
@@ -629,24 +734,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
629
734
  function onVisibilityChange() {
630
735
  if (document.visibilityState === "visible") {
631
736
  logger.debug("[zync] sync:start-in-foreground");
632
- enableSyncTimer(true);
737
+ startSyncTimer(true);
633
738
  } else {
634
739
  logger.debug("[zync] sync:pause-in-background");
635
- enableSyncTimer(false);
740
+ startSyncTimer(false);
636
741
  }
637
742
  }
638
743
  storeApi.sync = {
639
744
  enable,
640
- startFirstLoad: () => startFirstLoad(set, syncApi, logger)
745
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger),
746
+ resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
641
747
  };
642
- const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
748
+ const userState = stateCreator(set, get, setAndQueueToSync);
643
749
  return {
644
750
  ...userState,
645
751
  syncState: {
646
752
  // set defaults
647
753
  status: "hydrating",
648
754
  error: void 0,
649
- enabled: false,
755
+ conflicts: void 0,
650
756
  firstLoadDone: false,
651
757
  pendingChanges: [],
652
758
  lastPulled: {}
@@ -657,9 +763,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
657
763
  }
658
764
  export {
659
765
  SyncAction,
766
+ changeKeysFrom,
767
+ changeKeysTo,
660
768
  createIndexedDBStorage,
769
+ createLocalId,
661
770
  createWithSync,
662
- nextLocalId,
663
771
  persistWithSync
664
772
  };
665
773
  //# sourceMappingURL=index.js.map