@anfenn/zync 0.1.23 → 0.2.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/README.md CHANGED
@@ -2,16 +2,21 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@anfenn/zync.svg)](https://www.npmjs.com/package/@anfenn/zync)
4
4
 
5
- Unopinionated, bullet-proof, offline-first sync middleware for Zustand.
5
+ Simple, bullet-proof, offline-first sync middleware for Zustand.
6
+
7
+ **_STATUS_**: Actively developed in alpha stage while requirements are being understood. Api may change, requests are welcome.
6
8
 
7
9
  ## Benefits
8
10
 
9
- - Simple to sync any state with a backend
11
+ - Easy to sync non-nested array state with a backend (i.e. mirror remote database tables locally)
12
+ - **"It just works"** philosophy
13
+ - Batteries optionally included:
14
+ - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
10
15
  - Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
11
16
  - Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
12
17
  - Allows for idiomatic use of Zustand
13
18
  - Leaves the api requests up to you (RESTful, GraphQL, etc.), just provide add(), update(), remove() and list()
14
- - **_Coming soon_**: Customisable conflict resolution. Currently last-write-wins.
19
+ - **_Coming soon_**: Customisable conflict resolution. Currently local-wins.
15
20
 
16
21
  ## Requirements
17
22
 
@@ -30,7 +35,7 @@ npm install @anfenn/zync
30
35
  ### Zustand store creation (store.ts):
31
36
 
32
37
  ```ts
33
- import { SyncAction, type UseStoreWithSync, persistWithSync } from '@anfenn/zync';
38
+ import { type UseStoreWithSync, persistWithSync } from '@anfenn/zync';
34
39
  import { create } from 'zustand';
35
40
  import { createJSONStorage } from 'zustand/middleware';
36
41
  import { useShallow } from 'zustand/react/shallow';
@@ -45,32 +50,25 @@ type Store = {
45
50
 
46
51
  export const useStore = create<any>()(
47
52
  persistWithSync<Store>(
48
- (set, get, queueToSync) => ({
49
- // Standard Zustand state and mutation functions with new queueToSync()
53
+ (set, get, setAndSync) => ({
54
+ // Standard Zustand state and mutation functions with new setAndSync()
50
55
 
51
56
  facts: [],
52
57
  addFact: (item: Fact) => {
53
58
  const updated_at = new Date().toISOString();
54
59
  const newItem = { ...item, created_at: updated_at, updated_at };
55
60
 
56
- set((state: Store) => ({
61
+ setAndSync((state: Store) => ({
57
62
  facts: [...state.facts, newItem],
58
63
  }));
59
-
60
- // Never call queueToSync() inside Zustand set() due to itself calling set(), so may cause lost state changes
61
- queueToSync(SyncAction.CreateOrUpdate, 'facts', item._localId);
62
64
  },
63
65
  updateFact: (localId: string, changes: Partial<Fact>) => {
64
- set((state: Store) => ({
66
+ setAndSync((state: Store) => ({
65
67
  facts: state.facts.map((item) => (item._localId === localId ? { ...item, ...changes } : item)),
66
68
  }));
67
-
68
- queueToSync(SyncAction.CreateOrUpdate, 'facts', localId);
69
69
  },
70
70
  removeFact: (localId: string) => {
71
- queueToSync(SyncAction.Remove, 'facts', localId);
72
-
73
- set((state: Store) => ({
71
+ setAndSync((state: Store) => ({
74
72
  facts: state.facts.filter((item) => item._localId !== localId),
75
73
  }));
76
74
  },
@@ -107,6 +105,9 @@ export const useFacts = () =>
107
105
  );
108
106
  ```
109
107
 
108
+ **_NOTE_**: Zync uses an internal timer (setInterval) to sync, so it's advised to just have one store. You could have multiple, with different store names (see Zustand persist options above), but if both stores use Zync, although it would work fine, it wouldn't offer much advantage. If one store becomes large with many state keys and functions, then you could separate them into multiple files and import than with object spreading
109
+ `e.g. {...storeState1, ...storeState2}`
110
+
110
111
  ### In your component:
111
112
 
112
113
  ```ts
package/dist/index.cjs CHANGED
@@ -349,9 +349,42 @@ function findApi(stateKey, syncApi) {
349
349
  }
350
350
  return api;
351
351
  }
352
+ function findChanges(current, updated) {
353
+ const currentMap = /* @__PURE__ */ new Map();
354
+ for (const item of current) {
355
+ if (item && item._localId) {
356
+ currentMap.set(item._localId, item);
357
+ }
358
+ }
359
+ const changesMap = /* @__PURE__ */ new Map();
360
+ for (const item of updated) {
361
+ if (item && item._localId) {
362
+ const curr = currentMap.get(item._localId);
363
+ if (curr) {
364
+ const diff = {};
365
+ for (const key in curr) {
366
+ if (key !== "_localId" && curr[key] !== item[key]) {
367
+ diff[key] = item[key];
368
+ }
369
+ }
370
+ if (Object.keys(diff).length > 0) {
371
+ changesMap.set(item._localId, { currentItem: curr, updatedItem: item, changes: diff });
372
+ }
373
+ } else {
374
+ changesMap.set(item._localId, { currentItem: null, updatedItem: item, changes: item });
375
+ }
376
+ }
377
+ }
378
+ for (const [localId, curr] of currentMap) {
379
+ if (!updated.some((u) => u && u._localId === localId)) {
380
+ changesMap.set(localId, { currentItem: curr, updatedItem: null, changes: null });
381
+ }
382
+ }
383
+ return changesMap;
384
+ }
352
385
 
353
386
  // src/pull.ts
354
- async function pull(set, get, stateKey, api, logger) {
387
+ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
355
388
  const lastPulled = get().syncState.lastPulled || {};
356
389
  const lastPulledAt = new Date(lastPulled[stateKey] || /* @__PURE__ */ new Date(0));
357
390
  logger.debug(`[zync] pull:start stateKey=${stateKey} since=${lastPulledAt.toISOString()}`);
@@ -380,16 +413,53 @@ async function pull(set, get, stateKey, api, logger) {
380
413
  continue;
381
414
  }
382
415
  delete remote.deleted;
383
- const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
384
- if (localItem && !pending) {
385
- const merged = {
386
- ...localItem,
387
- ...remote,
388
- _localId: localItem._localId
389
- };
390
- nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
391
- logger.debug(`[zync] pull:merge stateKey=${stateKey} id=${remote.id}`);
392
- } else if (!localItem) {
416
+ if (localItem) {
417
+ const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
418
+ if (pendingChange) {
419
+ switch (conflictResolutionStrategy) {
420
+ case "local-wins":
421
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
422
+ break;
423
+ case "remote-wins": {
424
+ const merged = {
425
+ ...remote,
426
+ _localId: localItem._localId
427
+ };
428
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
429
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
430
+ break;
431
+ }
432
+ // case 'try-shallow-merge':
433
+ // // Try and merge all fields, fail if not possible due to conflicts
434
+ // // throw new ConflictError('Details...');
435
+ // Object.entries(pendingChange.changes || {}).map(([key, value]) => {
436
+ // const localValue = localItem[key];
437
+ // const remoteValue = remote[key];
438
+ // if (localValue !== undefined && localValue !== value) {
439
+ // //throw new ConflictError(`Conflict on ${key}: local=${localValue} remote=${value}`);
440
+ // }
441
+ // });
442
+ // break;
443
+ // case 'custom':
444
+ // // Hook to allow custom userland logic
445
+ // // const error = onConflict(localItem, remote, stateKey, pending);
446
+ // // logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id} error=${error}`);
447
+ // // if (error) throw new ConflictError(error);
448
+ // break;
449
+ default:
450
+ logger.error(`[zync] pull:conflict-strategy:unknown stateKey=${stateKey} id=${remote.id} strategy=${conflictResolutionStrategy}`);
451
+ break;
452
+ }
453
+ } else {
454
+ const merged = {
455
+ ...localItem,
456
+ ...remote,
457
+ _localId: localItem._localId
458
+ };
459
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
460
+ logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
461
+ }
462
+ } else {
393
463
  nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
394
464
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
395
465
  }
@@ -409,7 +479,7 @@ async function pull(set, get, stateKey, api, logger) {
409
479
 
410
480
  // src/push.ts
411
481
  var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
412
- async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
482
+ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
413
483
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
414
484
  const { action, stateKey, localId, id, version } = change;
415
485
  switch (action) {
@@ -449,26 +519,27 @@ async function pushOne(set, get, change, api, logger, queueToSync, missingStrate
449
519
  }
450
520
  return;
451
521
  } else {
452
- logger.warn("[zync] push:update:missing-remote", {
453
- stateKey,
454
- localId,
455
- id: item.id
456
- });
457
522
  switch (missingStrategy) {
458
523
  case "delete-local-record":
459
524
  set((s) => ({
460
525
  [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
461
526
  }));
527
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
462
528
  break;
463
- case "insert-remote-record": {
529
+ case "insert-remote-record":
464
530
  omittedItem._localId = crypto.randomUUID();
465
531
  omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
466
- set((s) => ({
532
+ setAndQueueToSync((s) => ({
467
533
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
468
534
  }));
469
- queueToSync("create-or-update" /* CreateOrUpdate */, stateKey, omittedItem._localId);
535
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
536
+ break;
537
+ case "ignore":
538
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
539
+ break;
540
+ default:
541
+ logger.error(`[zync] push:missing-remote:unknown stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
470
542
  break;
471
- }
472
543
  }
473
544
  removeFromPendingChanges(set, localId, stateKey);
474
545
  onMissingRemoteRecordDuringUpdate?.(missingStrategy, item, omittedItem._localId);
@@ -488,7 +559,7 @@ async function pushOne(set, get, change, api, logger, queueToSync, missingStrate
488
559
  if (samePendingVersion(get, stateKey, localId, version)) {
489
560
  removeFromPendingChanges(set, localId, stateKey);
490
561
  }
491
- onAfterRemoteAdd?.(set, get, queueToSync, stateKey, {
562
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
492
563
  ...item,
493
564
  ...result
494
565
  });
@@ -588,6 +659,7 @@ var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
588
659
  var DEFAULT_LOGGER = console;
589
660
  var DEFAULT_MIN_LOG_LEVEL = "debug";
590
661
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
662
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
591
663
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
592
664
  const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
593
665
  return new Promise((resolve) => {
@@ -599,6 +671,7 @@ function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {})
599
671
  function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
600
672
  const syncInterval = syncOptions.syncInterval ?? DEFAULT_SYNC_INTERVAL_MILLIS;
601
673
  const missingStrategy = syncOptions.missingRemoteRecordDuringUpdateStrategy ?? DEFAULT_MISSING_REMOTE_RECORD_STRATEGY;
674
+ const conflictResolutionStrategy = syncOptions.conflictResolutionStrategy ?? DEFAULT_CONFLICT_RESOLUTION_STRATEGY;
602
675
  const logger = newLogger(syncOptions.logger ?? DEFAULT_LOGGER, syncOptions.minLogLevel ?? DEFAULT_MIN_LOG_LEVEL);
603
676
  const baseOnRehydrate = persistOptions?.onRehydrateStorage;
604
677
  const basePartialize = persistOptions?.partialize;
@@ -654,15 +727,15 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
654
727
  for (const stateKey of Object.keys(syncApi)) {
655
728
  try {
656
729
  const api = findApi(stateKey, syncApi);
657
- await pull(set, get, stateKey, api, logger);
730
+ await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
658
731
  } catch (err) {
659
732
  syncError = syncError ?? err;
660
733
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
661
734
  }
662
735
  }
663
- const snapshot = [...get().syncState.pendingChanges || []];
664
- snapshot.sort((a, b) => orderFor(a.action) - orderFor(b.action));
665
- for (const change of snapshot) {
736
+ const changesSnapshot = [...get().syncState.pendingChanges || []];
737
+ changesSnapshot.sort((a, b) => orderFor(a.action) - orderFor(b.action));
738
+ for (const change of changesSnapshot) {
666
739
  try {
667
740
  const api = findApi(change.stateKey, syncApi);
668
741
  await pushOne(
@@ -671,7 +744,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
671
744
  change,
672
745
  api,
673
746
  logger,
674
- queueToSync,
747
+ setAndQueueToSync,
675
748
  missingStrategy,
676
749
  syncOptions.onMissingRemoteRecordDuringUpdate,
677
750
  syncOptions.onAfterRemoteAdd
@@ -755,40 +828,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
755
828
  }
756
829
  }));
757
830
  }
758
- function queueToSync(action, stateKey, ...localIds) {
759
- set((state) => {
760
- const pendingChanges = state.syncState.pendingChanges || [];
761
- for (const localId of localIds) {
762
- const item = state[stateKey].find((i) => i._localId === localId);
763
- if (!item) {
764
- logger.error(`[zync] queueToSync:no-local-item localId=${localId}`);
765
- continue;
766
- }
767
- const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
768
- if (queueItem) {
769
- queueItem.version += 1;
770
- if (queueItem.action === "create-or-update" /* CreateOrUpdate */ && action === "remove" /* Remove */ && item.id) {
771
- queueItem.action = "remove" /* Remove */;
772
- queueItem.id = item.id;
773
- logger.debug(`[zync] queueToSync:changed-to-remove action=${action} localId=${localId} v=${queueItem.version}`);
774
- } else {
775
- logger.debug(`[zync] queueToSync:re-queued action=${action} localId=${localId} v=${queueItem.version}`);
776
- }
777
- } else {
778
- pendingChanges.push({ action, stateKey, localId, id: item.id, version: 1 });
779
- logger.debug(`[zync] queueToSync:added action=${action} localId=${localId}`);
780
- }
781
- }
782
- return {
783
- syncState: {
784
- ...state.syncState || {},
785
- pendingChanges
786
- }
787
- };
788
- });
789
- syncOnce();
790
- }
791
- function setAndSync(partial) {
831
+ function setAndSyncOnce(partial) {
792
832
  if (typeof partial === "function") {
793
833
  set((state) => ({ ...partial(state) }));
794
834
  } else {
@@ -796,6 +836,49 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
796
836
  }
797
837
  syncOnce();
798
838
  }
839
+ function setAndQueueToSync(partial) {
840
+ if (typeof partial === "function") {
841
+ set((state) => newSyncState(state, partial(state)));
842
+ } else {
843
+ set((state) => newSyncState(state, partial));
844
+ }
845
+ syncOnce();
846
+ }
847
+ function newSyncState(state, partial) {
848
+ const pendingChanges = state.syncState.pendingChanges || [];
849
+ Object.keys(partial).map((stateKey) => {
850
+ const current = state[stateKey];
851
+ const updated = partial[stateKey];
852
+ const changes = findChanges(current, updated);
853
+ addToPendingChanges(pendingChanges, stateKey, changes);
854
+ });
855
+ return {
856
+ ...partial,
857
+ syncState: {
858
+ ...state.syncState || {},
859
+ pendingChanges
860
+ }
861
+ };
862
+ }
863
+ function addToPendingChanges(pendingChanges, stateKey, changes) {
864
+ for (const [localId, change] of changes) {
865
+ const action = change.updatedItem === null ? "remove" /* Remove */ : "create-or-update" /* CreateOrUpdate */;
866
+ const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
867
+ if (queueItem) {
868
+ queueItem.version += 1;
869
+ if (queueItem.action === "create-or-update" /* CreateOrUpdate */ && action === "remove" /* Remove */ && change.currentItem.id) {
870
+ queueItem.action = "remove" /* Remove */;
871
+ queueItem.id = change.currentItem.id;
872
+ logger.debug(`[zync] addToPendingChanges:changed-to-remove action=${action} localId=${localId} v=${queueItem.version}`);
873
+ } else {
874
+ logger.debug(`[zync] addToPendingChanges:re-queued action=${action} localId=${localId} v=${queueItem.version}`);
875
+ }
876
+ } else {
877
+ pendingChanges.push({ action, stateKey, localId, id: change.currentItem?.id, version: 1, changes: change.changes });
878
+ logger.debug(`[zync] addToPendingChanges:added action=${action} localId=${localId}`);
879
+ }
880
+ }
881
+ }
799
882
  function enable(enabled) {
800
883
  set((state) => ({
801
884
  syncState: {
@@ -834,7 +917,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
834
917
  enable,
835
918
  startFirstLoad
836
919
  };
837
- const userState = stateCreator(setAndSync, get, queueToSync);
920
+ const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
838
921
  return {
839
922
  ...userState,
840
923
  syncState: {