@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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Simple, unopinionated, bullet-proof, offline-first sync middleware for Zustand.
6
6
 
7
- **_STATUS_**: Actively developed in alpha stage while requirements are being understood. Api may change, requests are welcome.
7
+ **_STATUS_**: Actively developed in beta stage. Core features are complete, Api is stable, requests are welcome.
8
8
 
9
9
  ## Benefits
10
10
 
@@ -12,33 +12,38 @@ Simple, unopinionated, bullet-proof, offline-first sync middleware for Zustand.
12
12
  - **"It just works"** philosophy
13
13
  - Optimistic UI updates
14
14
  - Conflict resolution:
15
- - 'local-wins' | 'remote-wins' _**(More in development)**_
15
+ - 'client-wins' | 'server-wins' | 'try-shallow-merge'
16
+ - 'try-shallow-merge' allows the user to choose between client and server changes if conflicts are found
16
17
  - Missing remote record during update strategy, to prevent accidental server deletion from losing client data:
17
18
  - 'ignore' | 'delete-local-record' | 'insert-remote-record'
18
19
  - Batteries optionally included:
19
- - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
20
- - UUID helper
20
+ - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb)): `createIndexedDBStorage()`
21
+ - UUID helper: `createLocalId()`
22
+ - Object|Array key rename helpers to map endpoint fields to Zync: `changeKeysFrom()` & `changeKeysTo()`
21
23
  - Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
22
24
  - Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
23
25
  - Allows for idiomatic use of Zustand
24
26
  - Leaves the api requests up to you (RESTful, GraphQL, etc.), just provide add(), update(), remove() and list()
25
27
  - Client or server assigned primary key, of any datatype
28
+ - Fully tested on `localstorage` and `IndexedDB` (>80% code coverage)
29
+ - Client schema migrations are a breeze using Zustand's [migrate](https://zustand.docs.pmnd.rs/middlewares/persist#persisting-a-state-through-versioning-and-migrations) hook
30
+ - All Zync's internal state is accessible via the reactive `state.syncState` object
26
31
 
27
32
  ## Requirements
28
33
 
29
- - Client records will have a `_localId` field which is stable and never sent to the server. It is ideal for use as a key in JSX. The provided helper function `nextLocalId()` returns a UUID, but you could use any unique value
34
+ - Client records will have a `_localId` field which is stable and never sent to the server. It is ideal for use as a key in JSX. The provided helper function `createLocalId()` returns a UUID, but you could use any unique value
30
35
  - Server records must have:
31
36
 
32
37
  - `id`: Any datatype, can be a server OR client assigned value
33
38
  - `updated_at`: Server assigned **_millisecond_** timestamp (db trigger or api layer). The client will never send this as the client clock is unlikely to be in sync with the server, so is never used for change detection. If it has a higher precision than millisecond, like PostgreSQL's microsecond timestampz, updates could be ignored.
34
39
  - `deleted`: Boolean, used for soft deletes, to allow other clients to download deleted records to keep their local records in sync
35
40
 
36
- **_TIP: If your endpoint doesn't have the same names as the 3 fields above, you can map them in your `api.ts` file e.g. `deleted` -> `isDeleted`_**
41
+ **_TIP: If your endpoint doesn't have the same names as the 3 fields above, you can easily rename them in your `api.ts` file using the included `changeKeysFrom()` & `changeKeysTo()`_**
37
42
 
38
43
  ## Quickstart
39
44
 
40
45
  ```bash
41
- npm install @anfenn/zync
46
+ npm install zustand @anfenn/zync
42
47
  ```
43
48
 
44
49
  _The example below uses server assigned id's, but you can just set the id when creating an object for client assigned id's._
@@ -66,7 +71,7 @@ export const useStore = create<any>()(
66
71
 
67
72
  facts: [],
68
73
  addFact: (item: Fact) => {
69
- const updated_at = new Date().toISOString(); // Used as an optimistic UI update only, never sent to server
74
+ const updated_at = new Date().toISOString(); // Optimistic UI update only, never sent to server
70
75
  const newItem = { ...item, created_at: updated_at, updated_at };
71
76
 
72
77
  setAndSync((state: Store) => ({
@@ -108,9 +113,9 @@ export const useStore = create<any>()(
108
113
  // Triggered by the api.update() returning true or false confirming the existence of the remote record after an update
109
114
  missingRemoteRecordDuringUpdateStrategy: 'ignore',
110
115
 
111
- // Options: 'local-wins' | 'remote-wins' (More coming soon)
112
- // Default: 'local-wins'
113
- conflictResolutionStrategy: 'local-wins',
116
+ // Options: 'client-wins' | 'server-wins' | 'try-shallow-merge'
117
+ // Default: 'try-shallow-merge' (Conflicts are listed in syncState.conflicts)
118
+ conflictResolutionStrategy: 'try-shallow-merge',
114
119
  },
115
120
  ),
116
121
  ) as UseStoreWithSync<Store>;
@@ -133,7 +138,7 @@ export const useFacts = () =>
133
138
 
134
139
  ```ts
135
140
  import { useEffect } from 'react';
136
- import { nextLocalId } from '@anfenn/zync';
141
+ import { createLocalId } from '@anfenn/zync';
137
142
  import { useFacts, useStore } from './store';
138
143
 
139
144
  function App() {
@@ -142,17 +147,18 @@ function App() {
142
147
 
143
148
  // Zync's internal sync state
144
149
  const syncState = useStore((state) => state.syncState);
145
- // syncState.status // 'hydrating' | 'syncing' | 'idle'
150
+ // syncState.status // 'disabled' | 'hydrating' | 'syncing' | 'idle'
146
151
  // syncState.error
147
- // syncState.enabled
152
+ // syncState.conflicts
148
153
  // syncState.firstLoadDone
149
154
  // syncState.pendingChanges
150
155
  // syncState.lastPulled
151
156
 
152
157
  useEffect(() => {
153
158
  // Zync's control api
154
- useStore.sync.enable(true); // Defaults to false, enable to start syncing
155
- //useStore.sync.startFirstLoad(); // Batch loads from server
159
+ useStore.sync.enable(true); // Defaults to false, enable to start syncing
160
+ //useStore.sync.startFirstLoad(); // Batch loads from server
161
+ //useStore.sync.resolveConflict(localId, true); // Keep client or server changes for specific record
156
162
  }, []);
157
163
 
158
164
  return (
@@ -161,7 +167,7 @@ function App() {
161
167
  <button
162
168
  onClick={() =>
163
169
  addFact({
164
- _localId: nextLocalId(),
170
+ _localId: createLocalId(),
165
171
  title: 'New fact ' + Date.now(),
166
172
  })
167
173
  }
@@ -189,9 +195,8 @@ import { supabase } from './supabase'; // Please include your own :)
189
195
  export type Fact = {
190
196
  _localId: string;
191
197
  fact: string;
192
- // Server assigned fields
193
- id?: number;
194
- updated_at?: string;
198
+ id?: number; // Client OR server assigned
199
+ updated_at?: string; // Server assigned
195
200
  };
196
201
 
197
202
  export const factApi: ApiFunctions = { add, update, remove, list, firstLoad };
@@ -204,8 +209,8 @@ async function add(item: any): Promise<any | undefined> {
204
209
  throw new Error(error.message);
205
210
  }
206
211
 
207
- if (data && data.length > 0) {
208
- // Must return server id, and any other fields you want merged in
212
+ if (data?.length > 0) {
213
+ // Return server id if not using client assigned id's, and any other fields you want merged in
209
214
  return { id: data[0].id };
210
215
  }
211
216
  }
package/dist/index.cjs CHANGED
@@ -281,9 +281,11 @@ var init_build = __esm({
281
281
  var index_exports = {};
282
282
  __export(index_exports, {
283
283
  SyncAction: () => SyncAction,
284
+ changeKeysFrom: () => changeKeysFrom,
285
+ changeKeysTo: () => changeKeysTo,
284
286
  createIndexedDBStorage: () => createIndexedDBStorage,
287
+ createLocalId: () => createLocalId,
285
288
  createWithSync: () => createWithSync,
286
- nextLocalId: () => nextLocalId,
287
289
  persistWithSync: () => persistWithSync
288
290
  });
289
291
  module.exports = __toCommonJS(index_exports);
@@ -311,9 +313,40 @@ function newLogger(base, min) {
311
313
 
312
314
  // src/helpers.ts
313
315
  var SYNC_FIELDS = ["_localId", "updated_at", "deleted"];
314
- function nextLocalId() {
316
+ function createLocalId() {
315
317
  return crypto.randomUUID();
316
318
  }
319
+ function sleep(ms) {
320
+ return new Promise((resolve) => setTimeout(resolve, ms));
321
+ }
322
+ function changeKeysTo(input, toIdKey, toUpdatedAtKey, toDeletedKey) {
323
+ if (!input) return input;
324
+ const isArray = Array.isArray(input);
325
+ const result = (isArray ? input : [input]).map((item) => {
326
+ const { id, updated_at, deleted, ...rest } = item;
327
+ return {
328
+ [toIdKey]: id,
329
+ [toUpdatedAtKey]: updated_at,
330
+ [toDeletedKey]: deleted,
331
+ ...rest
332
+ };
333
+ });
334
+ return isArray ? result : result[0];
335
+ }
336
+ function changeKeysFrom(input, fromIdKey, fromUpdatedAtKey, fromDeletedKey) {
337
+ if (!input) return input;
338
+ const isArray = Array.isArray(input);
339
+ const result = (isArray ? input : [input]).map((item) => {
340
+ const { [fromIdKey]: id, [fromUpdatedAtKey]: updated_at, [fromDeletedKey]: deleted, ...rest } = item;
341
+ return {
342
+ id,
343
+ updated_at,
344
+ deleted,
345
+ ...rest
346
+ };
347
+ });
348
+ return isArray ? result : result[0];
349
+ }
317
350
  function orderFor(a) {
318
351
  switch (a) {
319
352
  case "create" /* Create */:
@@ -330,8 +363,8 @@ function omitSyncFields(item) {
330
363
  return result;
331
364
  }
332
365
  function samePendingVersion(get, stateKey, localId, version) {
333
- const q = get().syncState.pendingChanges || [];
334
- const curChange = q.find((p) => p.localId === localId && p.stateKey === stateKey);
366
+ const pending = get().syncState.pendingChanges || [];
367
+ const curChange = pending.find((p) => p.localId === localId && p.stateKey === stateKey);
335
368
  return curChange?.version === version;
336
369
  }
337
370
  function removeFromPendingChanges(set, localId, stateKey) {
@@ -365,7 +398,7 @@ function tryAddToPendingChanges(pendingChanges, stateKey, changes) {
365
398
  queueItem.changes = { ...queueItem.changes, ...omittedItem };
366
399
  }
367
400
  } else if (action === "remove" /* Remove */ || hasChanges) {
368
- pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem });
401
+ pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem, current: omitSyncFields(change.currentItem) });
369
402
  }
370
403
  }
371
404
  }
@@ -377,6 +410,23 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
377
410
  if (id) change.id = id;
378
411
  }
379
412
  }
413
+ function tryUpdateConflicts(pendingChanges, conflicts) {
414
+ if (!conflicts) return conflicts;
415
+ const newConflicts = { ...conflicts };
416
+ for (const change of pendingChanges) {
417
+ const conflict = newConflicts[change.localId];
418
+ if (conflict && change.changes) {
419
+ const newFields = conflict.fields.map((f) => {
420
+ if (f.key in change.changes) {
421
+ return { ...f, localValue: change.changes[f.key] };
422
+ }
423
+ return f;
424
+ });
425
+ newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
426
+ }
427
+ }
428
+ return newConflicts;
429
+ }
380
430
  function findApi(stateKey, syncApi) {
381
431
  const api = syncApi[stateKey];
382
432
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -418,6 +468,50 @@ function findChanges(current, updated) {
418
468
  }
419
469
  return changesMap;
420
470
  }
471
+ function hasKeysOrUndefined(obj) {
472
+ return Object.keys(obj).length === 0 ? void 0 : obj;
473
+ }
474
+ function hasConflicts(get, localId) {
475
+ const state = get();
476
+ if (state.syncState.conflicts) {
477
+ return !!state.syncState.conflicts[localId];
478
+ }
479
+ return false;
480
+ }
481
+ function resolveConflict(set, localId, keepLocalFields) {
482
+ set((state) => {
483
+ const syncState = state.syncState || {};
484
+ const conflicts = syncState.conflicts || {};
485
+ const conflict = conflicts[localId];
486
+ if (conflict) {
487
+ const items = state[conflict.stateKey];
488
+ const item = items.find((i) => i._localId === localId);
489
+ if (!item) {
490
+ return state;
491
+ }
492
+ const resolved = { ...item };
493
+ let pendingChanges = [...syncState.pendingChanges];
494
+ if (!keepLocalFields) {
495
+ for (const field of conflict.fields) {
496
+ resolved[field.key] = field.remoteValue;
497
+ }
498
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
499
+ }
500
+ const nextItems = items.map((i) => i._localId === localId ? resolved : i);
501
+ const nextConflicts = { ...conflicts };
502
+ delete nextConflicts[localId];
503
+ return {
504
+ [conflict.stateKey]: nextItems,
505
+ syncState: {
506
+ ...syncState,
507
+ pendingChanges,
508
+ conflicts: hasKeysOrUndefined(nextConflicts)
509
+ }
510
+ };
511
+ }
512
+ return state;
513
+ });
514
+ }
421
515
 
422
516
  // src/pull.ts
423
517
  async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
@@ -428,7 +522,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
428
522
  if (!serverData?.length) return;
429
523
  let newest = lastPulledAt;
430
524
  set((state) => {
431
- const pendingChanges = state.syncState.pendingChanges || [];
525
+ let pendingChanges = [...state.syncState.pendingChanges];
526
+ const conflicts = { ...state.syncState.conflicts };
432
527
  const localItems = state[stateKey] || [];
433
528
  let nextItems = [...localItems];
434
529
  const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
@@ -452,43 +547,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
452
547
  if (localItem) {
453
548
  const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
454
549
  if (pendingChange) {
550
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
455
551
  switch (conflictResolutionStrategy) {
456
- case "local-wins":
457
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
552
+ case "client-wins":
458
553
  break;
459
- case "remote-wins": {
460
- const merged = {
461
- ...remote,
462
- _localId: localItem._localId
463
- };
554
+ case "server-wins": {
555
+ const merged = { ...remote, _localId: localItem._localId };
464
556
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
465
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
557
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
466
558
  break;
467
559
  }
468
- // case 'try-shallow-merge':
469
- // // Try and merge all fields, fail if not possible due to conflicts
470
- // // throw new ConflictError('Details...');
471
- // break;
472
- // case 'custom':
473
- // // Hook to allow custom userland logic
474
- // // const error = onConflict(localItem, remote, stateKey, pending);
475
- // // logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id} error=${error}`);
476
- // // if (error) throw new ConflictError(error);
477
- // break;
478
- default:
479
- logger.error(`[zync] pull:conflict-strategy:unknown stateKey=${stateKey} id=${remote.id} strategy=${conflictResolutionStrategy}`);
560
+ case "try-shallow-merge": {
561
+ const changes = pendingChange.changes || {};
562
+ const current = pendingChange.current || {};
563
+ 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] }));
564
+ if (fields.length > 0) {
565
+ logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
566
+ conflicts[localItem._localId] = { stateKey, fields };
567
+ } else {
568
+ const localChangedKeys = Object.keys(changes);
569
+ const preservedLocal = { _localId: localItem._localId };
570
+ for (const k of localChangedKeys) {
571
+ if (k in localItem) preservedLocal[k] = localItem[k];
572
+ }
573
+ const merged = { ...remote, ...preservedLocal };
574
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
575
+ delete conflicts[localItem._localId];
576
+ }
480
577
  break;
578
+ }
481
579
  }
482
580
  } else {
483
- const merged = {
484
- ...localItem,
485
- ...remote
486
- };
581
+ const merged = { ...localItem, ...remote };
487
582
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
488
583
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
489
584
  }
490
585
  } else {
491
- nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
586
+ nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
492
587
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
493
588
  }
494
589
  }
@@ -496,6 +591,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
496
591
  [stateKey]: nextItems,
497
592
  syncState: {
498
593
  ...state.syncState || {},
594
+ pendingChanges,
595
+ conflicts: hasKeysOrUndefined(conflicts),
499
596
  lastPulled: {
500
597
  ...state.syncState.lastPulled || {},
501
598
  [stateKey]: newest.toISOString()
@@ -522,6 +619,10 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
522
619
  removeFromPendingChanges(set, localId, stateKey);
523
620
  break;
524
621
  case "update" /* Update */: {
622
+ if (hasConflicts(get, change.localId)) {
623
+ logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
624
+ return;
625
+ }
525
626
  const exists = await api.update(id, changesClone);
526
627
  if (exists) {
527
628
  logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
@@ -548,7 +649,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
548
649
  case "insert-remote-record": {
549
650
  const newItem = {
550
651
  ...item,
551
- _localId: nextLocalId(),
652
+ _localId: createLocalId(),
552
653
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
553
654
  };
554
655
  setAndQueueToSync((s) => ({
@@ -627,7 +728,7 @@ async function startFirstLoad(set, syncApi, logger) {
627
728
  } else {
628
729
  next.push({
629
730
  ...remote,
630
- _localId: nextLocalId()
731
+ _localId: createLocalId()
631
732
  });
632
733
  }
633
734
  }
@@ -735,17 +836,17 @@ function createIndexedDBStorage(options) {
735
836
  }
736
837
 
737
838
  // src/index.ts
738
- var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
739
- SyncAction2["Create"] = "create";
740
- SyncAction2["Update"] = "update";
741
- SyncAction2["Remove"] = "remove";
742
- return SyncAction2;
839
+ var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
840
+ SyncAction3["Create"] = "create";
841
+ SyncAction3["Update"] = "update";
842
+ SyncAction3["Remove"] = "remove";
843
+ return SyncAction3;
743
844
  })(SyncAction || {});
744
- var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
845
+ var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
745
846
  var DEFAULT_LOGGER = console;
746
847
  var DEFAULT_MIN_LOG_LEVEL = "debug";
747
848
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
748
- var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
849
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
749
850
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
750
851
  const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
751
852
  return new Promise((resolve) => {
@@ -782,7 +883,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
782
883
  syncState: {
783
884
  firstLoadDone: syncState.firstLoadDone,
784
885
  pendingChanges: syncState.pendingChanges,
785
- lastPulled: syncState.lastPulled
886
+ lastPulled: syncState.lastPulled,
887
+ conflicts: syncState.conflicts
786
888
  }
787
889
  };
788
890
  },
@@ -791,7 +893,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
791
893
  return {
792
894
  ...state,
793
895
  syncState: {
794
- ...state.syncState,
896
+ ...state.syncState || {},
795
897
  status: "idle"
796
898
  // this confirms 'hydrating' is done
797
899
  }
@@ -799,13 +901,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
799
901
  }
800
902
  };
801
903
  const creator = (set, get, storeApi) => {
802
- let syncIntervalId;
904
+ let syncTimerStarted = false;
803
905
  async function syncOnce() {
804
- const state = get();
805
- if (!state.syncState.enabled || state.syncState.status !== "idle") return;
806
- set((state2) => ({
906
+ if (get().syncState.status !== "idle") return;
907
+ set((state) => ({
807
908
  syncState: {
808
- ...state2.syncState || {},
909
+ ...state.syncState || {},
809
910
  status: "syncing"
810
911
  }
811
912
  }));
@@ -840,24 +941,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
840
941
  logger.error(`[zync] push:error change=${change}`, err);
841
942
  }
842
943
  }
843
- set((state2) => ({
944
+ set((state) => ({
844
945
  syncState: {
845
- ...state2.syncState || {},
946
+ ...state.syncState || {},
846
947
  status: "idle",
847
948
  error: firstSyncError
848
949
  }
849
950
  }));
850
- if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
851
- await syncOnce();
852
- }
853
- }
854
- function setAndSyncOnce(partial) {
855
- if (typeof partial === "function") {
856
- set((state) => ({ ...partial(state) }));
857
- } else {
858
- set(partial);
859
- }
860
- syncOnce();
861
951
  }
862
952
  function setAndQueueToSync(partial) {
863
953
  if (typeof partial === "function") {
@@ -865,7 +955,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
865
955
  } else {
866
956
  set((state) => newSyncState(state, partial));
867
957
  }
868
- syncOnce();
869
958
  }
870
959
  function newSyncState(state, partial) {
871
960
  const pendingChanges = state.syncState.pendingChanges || [];
@@ -875,11 +964,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
875
964
  const changes = findChanges(current, updated);
876
965
  tryAddToPendingChanges(pendingChanges, stateKey, changes);
877
966
  });
967
+ const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
878
968
  return {
879
969
  ...partial,
880
970
  syncState: {
881
971
  ...state.syncState || {},
882
- pendingChanges
972
+ pendingChanges,
973
+ conflicts
883
974
  }
884
975
  };
885
976
  }
@@ -887,18 +978,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
887
978
  set((state) => ({
888
979
  syncState: {
889
980
  ...state.syncState || {},
890
- enabled
981
+ status: enabled ? "idle" : "disabled"
891
982
  }
892
983
  }));
893
- enableSyncTimer(enabled);
984
+ startSyncTimer(enabled);
894
985
  addVisibilityChangeListener(enabled);
895
986
  }
896
- function enableSyncTimer(enabled) {
897
- clearInterval(syncIntervalId);
898
- syncIntervalId = void 0;
899
- if (enabled) {
900
- syncIntervalId = setInterval(syncOnce, syncInterval);
901
- syncOnce();
987
+ function startSyncTimer(start) {
988
+ if (start) {
989
+ tryStart();
990
+ } else {
991
+ syncTimerStarted = false;
992
+ }
993
+ }
994
+ async function tryStart() {
995
+ if (syncTimerStarted) return;
996
+ syncTimerStarted = true;
997
+ while (true) {
998
+ if (!syncTimerStarted) break;
999
+ await syncOnce();
1000
+ await sleep(syncInterval);
902
1001
  }
903
1002
  }
904
1003
  function addVisibilityChangeListener(add) {
@@ -911,24 +1010,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
911
1010
  function onVisibilityChange() {
912
1011
  if (document.visibilityState === "visible") {
913
1012
  logger.debug("[zync] sync:start-in-foreground");
914
- enableSyncTimer(true);
1013
+ startSyncTimer(true);
915
1014
  } else {
916
1015
  logger.debug("[zync] sync:pause-in-background");
917
- enableSyncTimer(false);
1016
+ startSyncTimer(false);
918
1017
  }
919
1018
  }
920
1019
  storeApi.sync = {
921
1020
  enable,
922
- startFirstLoad: () => startFirstLoad(set, syncApi, logger)
1021
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger),
1022
+ resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
923
1023
  };
924
- const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
1024
+ const userState = stateCreator(set, get, setAndQueueToSync);
925
1025
  return {
926
1026
  ...userState,
927
1027
  syncState: {
928
1028
  // set defaults
929
1029
  status: "hydrating",
930
1030
  error: void 0,
931
- enabled: false,
1031
+ conflicts: void 0,
932
1032
  firstLoadDone: false,
933
1033
  pendingChanges: [],
934
1034
  lastPulled: {}
@@ -940,9 +1040,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
940
1040
  // Annotate the CommonJS export names for ESM import in node:
941
1041
  0 && (module.exports = {
942
1042
  SyncAction,
1043
+ changeKeysFrom,
1044
+ changeKeysTo,
943
1045
  createIndexedDBStorage,
1046
+ createLocalId,
944
1047
  createWithSync,
945
- nextLocalId,
946
1048
  persistWithSync
947
1049
  });
948
1050
  //# sourceMappingURL=index.cjs.map