@anfenn/zync 0.3.3 → 0.4.0

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, current: omitSyncFields(change.currentItem) });
81
112
  }
82
113
  }
83
114
  }
@@ -89,6 +120,23 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
89
120
  if (id) change.id = id;
90
121
  }
91
122
  }
123
+ function tryUpdateConflicts(pendingChanges, conflicts) {
124
+ if (!conflicts) return conflicts;
125
+ const newConflicts = { ...conflicts };
126
+ for (const change of pendingChanges) {
127
+ const conflict = newConflicts[change.localId];
128
+ if (conflict && change.changes) {
129
+ const newFields = conflict.fields.map((f) => {
130
+ if (f.key in change.changes) {
131
+ return { ...f, localValue: change.changes[f.key] };
132
+ }
133
+ return f;
134
+ });
135
+ newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
136
+ }
137
+ }
138
+ return newConflicts;
139
+ }
92
140
  function findApi(stateKey, syncApi) {
93
141
  const api = syncApi[stateKey];
94
142
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -130,6 +178,50 @@ function findChanges(current, updated) {
130
178
  }
131
179
  return changesMap;
132
180
  }
181
+ function hasKeysOrUndefined(obj) {
182
+ return Object.keys(obj).length === 0 ? void 0 : obj;
183
+ }
184
+ function hasConflicts(get, localId) {
185
+ const state = get();
186
+ if (state.syncState.conflicts) {
187
+ return !!state.syncState.conflicts[localId];
188
+ }
189
+ return false;
190
+ }
191
+ function resolveConflict(set, localId, keepLocalFields) {
192
+ set((state) => {
193
+ const syncState = state.syncState || {};
194
+ const conflicts = syncState.conflicts || {};
195
+ const conflict = conflicts[localId];
196
+ if (conflict) {
197
+ const items = state[conflict.stateKey];
198
+ const item = items.find((i) => i._localId === localId);
199
+ if (!item) {
200
+ return state;
201
+ }
202
+ const resolved = { ...item };
203
+ let pendingChanges = [...syncState.pendingChanges];
204
+ if (!keepLocalFields) {
205
+ for (const field of conflict.fields) {
206
+ resolved[field.key] = field.remoteValue;
207
+ }
208
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
209
+ }
210
+ const nextItems = items.map((i) => i._localId === localId ? resolved : i);
211
+ const nextConflicts = { ...conflicts };
212
+ delete nextConflicts[localId];
213
+ return {
214
+ [conflict.stateKey]: nextItems,
215
+ syncState: {
216
+ ...syncState,
217
+ pendingChanges,
218
+ conflicts: hasKeysOrUndefined(nextConflicts)
219
+ }
220
+ };
221
+ }
222
+ return state;
223
+ });
224
+ }
133
225
 
134
226
  // src/pull.ts
135
227
  async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
@@ -140,7 +232,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
140
232
  if (!serverData?.length) return;
141
233
  let newest = lastPulledAt;
142
234
  set((state) => {
143
- const pendingChanges = state.syncState.pendingChanges || [];
235
+ let pendingChanges = [...state.syncState.pendingChanges];
236
+ const conflicts = { ...state.syncState.conflicts };
144
237
  const localItems = state[stateKey] || [];
145
238
  let nextItems = [...localItems];
146
239
  const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
@@ -164,43 +257,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
164
257
  if (localItem) {
165
258
  const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
166
259
  if (pendingChange) {
260
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
167
261
  switch (conflictResolutionStrategy) {
168
- case "local-wins":
169
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
262
+ case "client-wins":
170
263
  break;
171
- case "remote-wins": {
172
- const merged = {
173
- ...remote,
174
- _localId: localItem._localId
175
- };
264
+ case "server-wins": {
265
+ const merged = { ...remote, _localId: localItem._localId };
176
266
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
177
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
267
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
178
268
  break;
179
269
  }
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}`);
270
+ case "try-shallow-merge": {
271
+ const changes = pendingChange.changes || {};
272
+ const current = pendingChange.current || {};
273
+ const fields = Object.entries(changes).filter(([k, localValue]) => k in current && k in remote && current[k] !== remote[k] && localValue !== remote[k]).map(([key, localValue]) => ({ key, localValue, remoteValue: remote[key] }));
274
+ if (fields.length > 0) {
275
+ logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
276
+ conflicts[localItem._localId] = { stateKey, fields };
277
+ } else {
278
+ const localChangedKeys = Object.keys(changes);
279
+ const preservedLocal = { _localId: localItem._localId };
280
+ for (const k of localChangedKeys) {
281
+ if (k in localItem) preservedLocal[k] = localItem[k];
282
+ }
283
+ const merged = { ...remote, ...preservedLocal };
284
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
285
+ delete conflicts[localItem._localId];
286
+ }
192
287
  break;
288
+ }
193
289
  }
194
290
  } else {
195
- const merged = {
196
- ...localItem,
197
- ...remote
198
- };
291
+ const merged = { ...localItem, ...remote };
199
292
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
200
293
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
201
294
  }
202
295
  } else {
203
- nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
296
+ nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
204
297
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
205
298
  }
206
299
  }
@@ -208,6 +301,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
208
301
  [stateKey]: nextItems,
209
302
  syncState: {
210
303
  ...state.syncState || {},
304
+ pendingChanges,
305
+ conflicts: hasKeysOrUndefined(conflicts),
211
306
  lastPulled: {
212
307
  ...state.syncState.lastPulled || {},
213
308
  [stateKey]: newest.toISOString()
@@ -234,6 +329,10 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
234
329
  removeFromPendingChanges(set, localId, stateKey);
235
330
  break;
236
331
  case "update" /* Update */: {
332
+ if (hasConflicts(get, change.localId)) {
333
+ logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
334
+ return;
335
+ }
237
336
  const exists = await api.update(id, changesClone);
238
337
  if (exists) {
239
338
  logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
@@ -260,7 +359,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
260
359
  case "insert-remote-record": {
261
360
  const newItem = {
262
361
  ...item,
263
- _localId: nextLocalId(),
362
+ _localId: createLocalId(),
264
363
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
265
364
  };
266
365
  setAndQueueToSync((s) => ({
@@ -339,7 +438,7 @@ async function startFirstLoad(set, syncApi, logger) {
339
438
  } else {
340
439
  next.push({
341
440
  ...remote,
342
- _localId: nextLocalId()
441
+ _localId: createLocalId()
343
442
  });
344
443
  }
345
444
  }
@@ -453,17 +552,17 @@ function createIndexedDBStorage(options) {
453
552
  }
454
553
 
455
554
  // src/index.ts
456
- var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
457
- SyncAction2["Create"] = "create";
458
- SyncAction2["Update"] = "update";
459
- SyncAction2["Remove"] = "remove";
460
- return SyncAction2;
555
+ var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
556
+ SyncAction3["Create"] = "create";
557
+ SyncAction3["Update"] = "update";
558
+ SyncAction3["Remove"] = "remove";
559
+ return SyncAction3;
461
560
  })(SyncAction || {});
462
- var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
561
+ var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
463
562
  var DEFAULT_LOGGER = console;
464
563
  var DEFAULT_MIN_LOG_LEVEL = "debug";
465
564
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
466
- var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
565
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
467
566
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
468
567
  const store = create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
469
568
  return new Promise((resolve) => {
@@ -500,7 +599,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
500
599
  syncState: {
501
600
  firstLoadDone: syncState.firstLoadDone,
502
601
  pendingChanges: syncState.pendingChanges,
503
- lastPulled: syncState.lastPulled
602
+ lastPulled: syncState.lastPulled,
603
+ conflicts: syncState.conflicts
504
604
  }
505
605
  };
506
606
  },
@@ -509,7 +609,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
509
609
  return {
510
610
  ...state,
511
611
  syncState: {
512
- ...state.syncState,
612
+ ...state.syncState || {},
513
613
  status: "idle"
514
614
  // this confirms 'hydrating' is done
515
615
  }
@@ -517,13 +617,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
517
617
  }
518
618
  };
519
619
  const creator = (set, get, storeApi) => {
520
- let syncIntervalId;
620
+ let syncTimerStarted = false;
521
621
  async function syncOnce() {
522
- const state = get();
523
- if (!state.syncState.enabled || state.syncState.status !== "idle") return;
524
- set((state2) => ({
622
+ if (get().syncState.status !== "idle") return;
623
+ set((state) => ({
525
624
  syncState: {
526
- ...state2.syncState || {},
625
+ ...state.syncState || {},
527
626
  status: "syncing"
528
627
  }
529
628
  }));
@@ -558,24 +657,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
558
657
  logger.error(`[zync] push:error change=${change}`, err);
559
658
  }
560
659
  }
561
- set((state2) => ({
660
+ set((state) => ({
562
661
  syncState: {
563
- ...state2.syncState || {},
662
+ ...state.syncState || {},
564
663
  status: "idle",
565
664
  error: firstSyncError
566
665
  }
567
666
  }));
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
667
  }
580
668
  function setAndQueueToSync(partial) {
581
669
  if (typeof partial === "function") {
@@ -583,7 +671,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
583
671
  } else {
584
672
  set((state) => newSyncState(state, partial));
585
673
  }
586
- syncOnce();
587
674
  }
588
675
  function newSyncState(state, partial) {
589
676
  const pendingChanges = state.syncState.pendingChanges || [];
@@ -593,11 +680,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
593
680
  const changes = findChanges(current, updated);
594
681
  tryAddToPendingChanges(pendingChanges, stateKey, changes);
595
682
  });
683
+ const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
596
684
  return {
597
685
  ...partial,
598
686
  syncState: {
599
687
  ...state.syncState || {},
600
- pendingChanges
688
+ pendingChanges,
689
+ conflicts
601
690
  }
602
691
  };
603
692
  }
@@ -605,18 +694,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
605
694
  set((state) => ({
606
695
  syncState: {
607
696
  ...state.syncState || {},
608
- enabled
697
+ status: enabled ? "idle" : "disabled"
609
698
  }
610
699
  }));
611
- enableSyncTimer(enabled);
700
+ startSyncTimer(enabled);
612
701
  addVisibilityChangeListener(enabled);
613
702
  }
614
- function enableSyncTimer(enabled) {
615
- clearInterval(syncIntervalId);
616
- syncIntervalId = void 0;
617
- if (enabled) {
618
- syncIntervalId = setInterval(syncOnce, syncInterval);
619
- syncOnce();
703
+ function startSyncTimer(start) {
704
+ if (start) {
705
+ tryStart();
706
+ } else {
707
+ syncTimerStarted = false;
708
+ }
709
+ }
710
+ async function tryStart() {
711
+ if (syncTimerStarted) return;
712
+ syncTimerStarted = true;
713
+ while (true) {
714
+ if (!syncTimerStarted) break;
715
+ await syncOnce();
716
+ await sleep(syncInterval);
620
717
  }
621
718
  }
622
719
  function addVisibilityChangeListener(add) {
@@ -629,24 +726,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
629
726
  function onVisibilityChange() {
630
727
  if (document.visibilityState === "visible") {
631
728
  logger.debug("[zync] sync:start-in-foreground");
632
- enableSyncTimer(true);
729
+ startSyncTimer(true);
633
730
  } else {
634
731
  logger.debug("[zync] sync:pause-in-background");
635
- enableSyncTimer(false);
732
+ startSyncTimer(false);
636
733
  }
637
734
  }
638
735
  storeApi.sync = {
639
736
  enable,
640
- startFirstLoad: () => startFirstLoad(set, syncApi, logger)
737
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger),
738
+ resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
641
739
  };
642
- const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
740
+ const userState = stateCreator(set, get, setAndQueueToSync);
643
741
  return {
644
742
  ...userState,
645
743
  syncState: {
646
744
  // set defaults
647
745
  status: "hydrating",
648
746
  error: void 0,
649
- enabled: false,
747
+ conflicts: void 0,
650
748
  firstLoadDone: false,
651
749
  pendingChanges: [],
652
750
  lastPulled: {}
@@ -657,9 +755,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
657
755
  }
658
756
  export {
659
757
  SyncAction,
758
+ changeKeysFrom,
759
+ changeKeysTo,
660
760
  createIndexedDBStorage,
761
+ createLocalId,
661
762
  createWithSync,
662
- nextLocalId,
663
763
  persistWithSync
664
764
  };
665
765
  //# sourceMappingURL=index.js.map