@anfenn/zync 0.1.22 → 0.2.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/README.md CHANGED
@@ -2,16 +2,19 @@
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
6
 
7
7
  ## Benefits
8
8
 
9
- - Simple to sync any state with a backend
9
+ - Easy to sync non-nested array state with a backend (i.e. mirror remote database tables locally)
10
+ - **"It just works"** philosophy
11
+ - Batteries optionally included:
12
+ - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
10
13
  - Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
11
14
  - Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
12
15
  - Allows for idiomatic use of Zustand
13
16
  - 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.
17
+ - **_Coming soon_**: Customisable conflict resolution. Currently local-wins.
15
18
 
16
19
  ## Requirements
17
20
 
@@ -30,7 +33,7 @@ npm install @anfenn/zync
30
33
  ### Zustand store creation (store.ts):
31
34
 
32
35
  ```ts
33
- import { SyncAction, type UseStoreWithSync, persistWithSync } from '@anfenn/zync';
36
+ import { type UseStoreWithSync, persistWithSync } from '@anfenn/zync';
34
37
  import { create } from 'zustand';
35
38
  import { createJSONStorage } from 'zustand/middleware';
36
39
  import { useShallow } from 'zustand/react/shallow';
@@ -45,32 +48,25 @@ type Store = {
45
48
 
46
49
  export const useStore = create<any>()(
47
50
  persistWithSync<Store>(
48
- (set, get, queueToSync) => ({
49
- // Standard Zustand state and mutation functions with new queueToSync()
51
+ (set, get, setAndSync) => ({
52
+ // Standard Zustand state and mutation functions with new setAndSync()
50
53
 
51
54
  facts: [],
52
55
  addFact: (item: Fact) => {
53
56
  const updated_at = new Date().toISOString();
54
57
  const newItem = { ...item, created_at: updated_at, updated_at };
55
58
 
56
- set((state: Store) => ({
59
+ setAndSync((state: Store) => ({
57
60
  facts: [...state.facts, newItem],
58
61
  }));
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
62
  },
63
63
  updateFact: (localId: string, changes: Partial<Fact>) => {
64
- set((state: Store) => ({
64
+ setAndSync((state: Store) => ({
65
65
  facts: state.facts.map((item) => (item._localId === localId ? { ...item, ...changes } : item)),
66
66
  }));
67
-
68
- queueToSync(SyncAction.CreateOrUpdate, 'facts', localId);
69
67
  },
70
68
  removeFact: (localId: string) => {
71
- queueToSync(SyncAction.Remove, 'facts', localId);
72
-
73
- set((state: Store) => ({
69
+ setAndSync((state: Store) => ({
74
70
  facts: state.facts.filter((item) => item._localId !== localId),
75
71
  }));
76
72
  },
@@ -107,6 +103,9 @@ export const useFacts = () =>
107
103
  );
108
104
  ```
109
105
 
106
+ **_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
107
+ `e.g. {...storeState1, ...storeState2}`
108
+
110
109
  ### In your component:
111
110
 
112
111
  ```ts
@@ -242,23 +241,19 @@ async function firstLoad(lastId: any) {
242
241
 
243
242
  ## Optional IndexedDB storage
244
243
 
245
- When using IndexedDB Zustand saves the whole store under one key, which means indexes cannot be used to accelerate querying. However, if this becomes a performance issue due to the size of the store, then libraries like dexie.js instead of Zustand would be a better solution and provide the syntax for high performance queries.
244
+ Using async IndexedDB over sync localStorage gives the advantage of a responsive UI when reading/writing a very large store, as IndexedDB is running in it's own thread.
246
245
 
247
246
  If you want to use the bundled `createIndexedDBStorage()` helper, install `idb` in your project. It's intentionally optional so projects that don't use IndexedDB won't pull the dependency into their bundles.
248
247
 
249
- Install for runtime usage:
248
+ [idb](https://www.npmjs.com/package/idb) is an extremely popular and lightweight wrapper to simplify IndexedDB's verbose events based api into a simple Promise based one. It also handles the inconsistencies found when running in different browsers.
250
249
 
251
250
  ```bash
252
251
  npm install idb
253
252
  ```
254
253
 
255
- Or add it as an optional dependency of your package:
256
-
257
- ```bash
258
- npm install --save-optional idb
259
- ```
254
+ When using IndexedDB Zustand saves the whole store under one key, which means indexes cannot be used to accelerate querying. However, if this becomes a performance issue due to the size of the store, then libraries like dexie.js instead of Zustand would be a better solution and provide the syntax for high performance queries.
260
255
 
261
- The library will throw a helpful runtime error if `idb` isn't installed when `createIndexedDBStorage()` is invoked.
256
+ From testing I've found Zustand and Zync are lightening fast even with 100,000 average sized state objects.
262
257
 
263
258
  ## Community
264
259
 
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()}`);
@@ -381,15 +414,45 @@ async function pull(set, get, stateKey, api, logger) {
381
414
  }
382
415
  delete remote.deleted;
383
416
  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) {
417
+ if (localItem) {
418
+ if (pending) {
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
+ // break;
436
+ // case 'custom':
437
+ // // Hook to allow custom userland logic
438
+ // // const error = onConflict(localItem, remote, stateKey, pending);
439
+ // // logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id} error=${error}`);
440
+ // // if (error) throw new ConflictError(error);
441
+ // break;
442
+ default:
443
+ logger.error(`[zync] pull:conflict-strategy:unknown stateKey=${stateKey} id=${remote.id} strategy=${conflictResolutionStrategy}`);
444
+ break;
445
+ }
446
+ } else {
447
+ const merged = {
448
+ ...localItem,
449
+ ...remote,
450
+ _localId: localItem._localId
451
+ };
452
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
453
+ logger.debug(`[zync] pull:merge stateKey=${stateKey} id=${remote.id}`);
454
+ }
455
+ } else {
393
456
  nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
394
457
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
395
458
  }
@@ -409,7 +472,7 @@ async function pull(set, get, stateKey, api, logger) {
409
472
 
410
473
  // src/push.ts
411
474
  var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
412
- async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
475
+ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
413
476
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
414
477
  const { action, stateKey, localId, id, version } = change;
415
478
  switch (action) {
@@ -449,26 +512,27 @@ async function pushOne(set, get, change, api, logger, queueToSync, missingStrate
449
512
  }
450
513
  return;
451
514
  } else {
452
- logger.warn("[zync] push:update:missing-remote", {
453
- stateKey,
454
- localId,
455
- id: item.id
456
- });
457
515
  switch (missingStrategy) {
458
516
  case "delete-local-record":
459
517
  set((s) => ({
460
518
  [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
461
519
  }));
520
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
462
521
  break;
463
- case "insert-remote-record": {
522
+ case "insert-remote-record":
464
523
  omittedItem._localId = crypto.randomUUID();
465
524
  omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
466
- set((s) => ({
525
+ setAndQueueToSync((s) => ({
467
526
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
468
527
  }));
469
- queueToSync("create-or-update" /* CreateOrUpdate */, stateKey, omittedItem._localId);
528
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
529
+ break;
530
+ case "ignore":
531
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
532
+ break;
533
+ default:
534
+ logger.error(`[zync] push:missing-remote:unknown stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
470
535
  break;
471
- }
472
536
  }
473
537
  removeFromPendingChanges(set, localId, stateKey);
474
538
  onMissingRemoteRecordDuringUpdate?.(missingStrategy, item, omittedItem._localId);
@@ -488,7 +552,7 @@ async function pushOne(set, get, change, api, logger, queueToSync, missingStrate
488
552
  if (samePendingVersion(get, stateKey, localId, version)) {
489
553
  removeFromPendingChanges(set, localId, stateKey);
490
554
  }
491
- onAfterRemoteAdd?.(set, get, queueToSync, stateKey, {
555
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
492
556
  ...item,
493
557
  ...result
494
558
  });
@@ -588,6 +652,7 @@ var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
588
652
  var DEFAULT_LOGGER = console;
589
653
  var DEFAULT_MIN_LOG_LEVEL = "debug";
590
654
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
655
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
591
656
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
592
657
  const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
593
658
  return new Promise((resolve) => {
@@ -599,6 +664,7 @@ function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {})
599
664
  function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
600
665
  const syncInterval = syncOptions.syncInterval ?? DEFAULT_SYNC_INTERVAL_MILLIS;
601
666
  const missingStrategy = syncOptions.missingRemoteRecordDuringUpdateStrategy ?? DEFAULT_MISSING_REMOTE_RECORD_STRATEGY;
667
+ const conflictResolutionStrategy = syncOptions.conflictResolutionStrategy ?? DEFAULT_CONFLICT_RESOLUTION_STRATEGY;
602
668
  const logger = newLogger(syncOptions.logger ?? DEFAULT_LOGGER, syncOptions.minLogLevel ?? DEFAULT_MIN_LOG_LEVEL);
603
669
  const baseOnRehydrate = persistOptions?.onRehydrateStorage;
604
670
  const basePartialize = persistOptions?.partialize;
@@ -654,15 +720,15 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
654
720
  for (const stateKey of Object.keys(syncApi)) {
655
721
  try {
656
722
  const api = findApi(stateKey, syncApi);
657
- await pull(set, get, stateKey, api, logger);
723
+ await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
658
724
  } catch (err) {
659
725
  syncError = syncError ?? err;
660
726
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
661
727
  }
662
728
  }
663
- const snapshot = [...get().syncState.pendingChanges || []];
664
- snapshot.sort((a, b) => orderFor(a.action) - orderFor(b.action));
665
- for (const change of snapshot) {
729
+ const changesSnapshot = [...get().syncState.pendingChanges || []];
730
+ changesSnapshot.sort((a, b) => orderFor(a.action) - orderFor(b.action));
731
+ for (const change of changesSnapshot) {
666
732
  try {
667
733
  const api = findApi(change.stateKey, syncApi);
668
734
  await pushOne(
@@ -671,7 +737,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
671
737
  change,
672
738
  api,
673
739
  logger,
674
- queueToSync,
740
+ setAndQueueToSync,
675
741
  missingStrategy,
676
742
  syncOptions.onMissingRemoteRecordDuringUpdate,
677
743
  syncOptions.onAfterRemoteAdd
@@ -755,40 +821,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
755
821
  }
756
822
  }));
757
823
  }
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) {
824
+ function setAndSyncOnce(partial) {
792
825
  if (typeof partial === "function") {
793
826
  set((state) => ({ ...partial(state) }));
794
827
  } else {
@@ -796,6 +829,49 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
796
829
  }
797
830
  syncOnce();
798
831
  }
832
+ function setAndQueueToSync(partial) {
833
+ if (typeof partial === "function") {
834
+ set((state) => newSyncState(state, partial(state)));
835
+ } else {
836
+ set((state) => newSyncState(state, partial));
837
+ }
838
+ syncOnce();
839
+ }
840
+ function newSyncState(state, partial) {
841
+ const pendingChanges = state.syncState.pendingChanges || [];
842
+ Object.keys(partial).map((stateKey) => {
843
+ const current = state[stateKey];
844
+ const updated = partial[stateKey];
845
+ const changes = findChanges(current, updated);
846
+ addToPendingChanges(pendingChanges, stateKey, changes);
847
+ });
848
+ return {
849
+ ...partial,
850
+ syncState: {
851
+ ...state.syncState || {},
852
+ pendingChanges
853
+ }
854
+ };
855
+ }
856
+ function addToPendingChanges(pendingChanges, stateKey, changes) {
857
+ for (const [localId, change] of changes) {
858
+ const action = change.updatedItem === null ? "remove" /* Remove */ : "create-or-update" /* CreateOrUpdate */;
859
+ const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
860
+ if (queueItem) {
861
+ queueItem.version += 1;
862
+ if (queueItem.action === "create-or-update" /* CreateOrUpdate */ && action === "remove" /* Remove */ && change.currentItem.id) {
863
+ queueItem.action = "remove" /* Remove */;
864
+ queueItem.id = change.currentItem.id;
865
+ logger.debug(`[zync] addToPendingChanges:changed-to-remove action=${action} localId=${localId} v=${queueItem.version}`);
866
+ } else {
867
+ logger.debug(`[zync] addToPendingChanges:re-queued action=${action} localId=${localId} v=${queueItem.version}`);
868
+ }
869
+ } else {
870
+ pendingChanges.push({ action, stateKey, localId, id: change.currentItem?.id, version: 1 });
871
+ logger.debug(`[zync] addToPendingChanges:added action=${action} localId=${localId}`);
872
+ }
873
+ }
874
+ }
799
875
  function enable(enabled) {
800
876
  set((state) => ({
801
877
  syncState: {
@@ -834,7 +910,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
834
910
  enable,
835
911
  startFirstLoad
836
912
  };
837
- const userState = stateCreator(setAndSync, get, queueToSync);
913
+ const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
838
914
  return {
839
915
  ...userState,
840
916
  syncState: {