@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/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 };
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, before: omitSyncFields(change.currentItem) });
369
402
  }
370
403
  }
371
404
  }
@@ -377,6 +410,30 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
377
410
  if (id) change.id = id;
378
411
  }
379
412
  }
413
+ function setPendingChangeBefore(get, stateKey, localId, before) {
414
+ const pendingChanges = get().syncState.pendingChanges || [];
415
+ const change = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
416
+ if (change) {
417
+ change.before = { ...change.before, ...before };
418
+ }
419
+ }
420
+ function tryUpdateConflicts(pendingChanges, conflicts) {
421
+ if (!conflicts) return conflicts;
422
+ const newConflicts = { ...conflicts };
423
+ for (const change of pendingChanges) {
424
+ const conflict = newConflicts[change.localId];
425
+ if (conflict && change.changes) {
426
+ const newFields = conflict.fields.map((f) => {
427
+ if (f.key in change.changes) {
428
+ return { ...f, localValue: change.changes[f.key] };
429
+ }
430
+ return f;
431
+ });
432
+ newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
433
+ }
434
+ }
435
+ return newConflicts;
436
+ }
380
437
  function findApi(stateKey, syncApi) {
381
438
  const api = syncApi[stateKey];
382
439
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -418,6 +475,50 @@ function findChanges(current, updated) {
418
475
  }
419
476
  return changesMap;
420
477
  }
478
+ function hasKeysOrUndefined(obj) {
479
+ return Object.keys(obj).length === 0 ? void 0 : obj;
480
+ }
481
+ function hasConflicts(get, localId) {
482
+ const state = get();
483
+ if (state.syncState.conflicts) {
484
+ return !!state.syncState.conflicts[localId];
485
+ }
486
+ return false;
487
+ }
488
+ function resolveConflict(set, localId, keepLocalFields) {
489
+ set((state) => {
490
+ const syncState = state.syncState || {};
491
+ const conflicts = syncState.conflicts || {};
492
+ const conflict = conflicts[localId];
493
+ if (conflict) {
494
+ const items = state[conflict.stateKey];
495
+ const item = items.find((i) => i._localId === localId);
496
+ if (!item) {
497
+ return state;
498
+ }
499
+ const resolved = { ...item };
500
+ let pendingChanges = [...syncState.pendingChanges];
501
+ if (!keepLocalFields) {
502
+ for (const field of conflict.fields) {
503
+ resolved[field.key] = field.remoteValue;
504
+ }
505
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
506
+ }
507
+ const nextItems = items.map((i) => i._localId === localId ? resolved : i);
508
+ const nextConflicts = { ...conflicts };
509
+ delete nextConflicts[localId];
510
+ return {
511
+ [conflict.stateKey]: nextItems,
512
+ syncState: {
513
+ ...syncState,
514
+ pendingChanges,
515
+ conflicts: hasKeysOrUndefined(nextConflicts)
516
+ }
517
+ };
518
+ }
519
+ return state;
520
+ });
521
+ }
421
522
 
422
523
  // src/pull.ts
423
524
  async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
@@ -428,7 +529,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
428
529
  if (!serverData?.length) return;
429
530
  let newest = lastPulledAt;
430
531
  set((state) => {
431
- const pendingChanges = state.syncState.pendingChanges || [];
532
+ let pendingChanges = [...state.syncState.pendingChanges];
533
+ const conflicts = { ...state.syncState.conflicts };
432
534
  const localItems = state[stateKey] || [];
433
535
  let nextItems = [...localItems];
434
536
  const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
@@ -452,43 +554,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
452
554
  if (localItem) {
453
555
  const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
454
556
  if (pendingChange) {
557
+ logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
455
558
  switch (conflictResolutionStrategy) {
456
- case "local-wins":
457
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
559
+ case "client-wins":
458
560
  break;
459
- case "remote-wins": {
460
- const merged = {
461
- ...remote,
462
- _localId: localItem._localId
463
- };
561
+ case "server-wins": {
562
+ const merged = { ...remote, _localId: localItem._localId };
464
563
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
465
- logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
564
+ pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
466
565
  break;
467
566
  }
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}`);
567
+ case "try-shallow-merge": {
568
+ const changes = pendingChange.changes || {};
569
+ const before = pendingChange.before || {};
570
+ 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] }));
571
+ if (fields.length > 0) {
572
+ logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
573
+ conflicts[localItem._localId] = { stateKey, fields };
574
+ } else {
575
+ const localChangedKeys = Object.keys(changes);
576
+ const preservedLocal = { _localId: localItem._localId };
577
+ for (const k of localChangedKeys) {
578
+ if (k in localItem) preservedLocal[k] = localItem[k];
579
+ }
580
+ const merged = { ...remote, ...preservedLocal };
581
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
582
+ delete conflicts[localItem._localId];
583
+ }
480
584
  break;
585
+ }
481
586
  }
482
587
  } else {
483
- const merged = {
484
- ...localItem,
485
- ...remote
486
- };
588
+ const merged = { ...localItem, ...remote };
487
589
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
488
590
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
489
591
  }
490
592
  } else {
491
- nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
593
+ nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
492
594
  logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
493
595
  }
494
596
  }
@@ -496,6 +598,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
496
598
  [stateKey]: nextItems,
497
599
  syncState: {
498
600
  ...state.syncState || {},
601
+ pendingChanges,
602
+ conflicts: hasKeysOrUndefined(conflicts),
499
603
  lastPulled: {
500
604
  ...state.syncState.lastPulled || {},
501
605
  [stateKey]: newest.toISOString()
@@ -509,7 +613,6 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
509
613
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
510
614
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
511
615
  const { action, stateKey, localId, id, version, changes } = change;
512
- const changesClone = { ...changes };
513
616
  switch (action) {
514
617
  case "remove" /* Remove */:
515
618
  if (!id) {
@@ -522,11 +625,17 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
522
625
  removeFromPendingChanges(set, localId, stateKey);
523
626
  break;
524
627
  case "update" /* Update */: {
525
- const exists = await api.update(id, changesClone);
628
+ if (hasConflicts(get, change.localId)) {
629
+ logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
630
+ return;
631
+ }
632
+ const exists = await api.update(id, changes);
526
633
  if (exists) {
527
634
  logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
528
635
  if (samePendingVersion(get, stateKey, localId, version)) {
529
636
  removeFromPendingChanges(set, localId, stateKey);
637
+ } else {
638
+ setPendingChangeBefore(get, stateKey, localId, changes);
530
639
  }
531
640
  return;
532
641
  } else {
@@ -548,7 +657,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
548
657
  case "insert-remote-record": {
549
658
  const newItem = {
550
659
  ...item,
551
- _localId: nextLocalId(),
660
+ _localId: createLocalId(),
552
661
  updated_at: (/* @__PURE__ */ new Date()).toISOString()
553
662
  };
554
663
  setAndQueueToSync((s) => ({
@@ -570,7 +679,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
570
679
  break;
571
680
  }
572
681
  case "create" /* Create */: {
573
- const result = await api.add(changesClone);
682
+ const result = await api.add(changes);
574
683
  if (result) {
575
684
  logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
576
685
  set((s) => ({
@@ -627,7 +736,7 @@ async function startFirstLoad(set, syncApi, logger) {
627
736
  } else {
628
737
  next.push({
629
738
  ...remote,
630
- _localId: nextLocalId()
739
+ _localId: createLocalId()
631
740
  });
632
741
  }
633
742
  }
@@ -735,17 +844,17 @@ function createIndexedDBStorage(options) {
735
844
  }
736
845
 
737
846
  // src/index.ts
738
- var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
739
- SyncAction2["Create"] = "create";
740
- SyncAction2["Update"] = "update";
741
- SyncAction2["Remove"] = "remove";
742
- return SyncAction2;
847
+ var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
848
+ SyncAction3["Create"] = "create";
849
+ SyncAction3["Update"] = "update";
850
+ SyncAction3["Remove"] = "remove";
851
+ return SyncAction3;
743
852
  })(SyncAction || {});
744
- var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
853
+ var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
745
854
  var DEFAULT_LOGGER = console;
746
855
  var DEFAULT_MIN_LOG_LEVEL = "debug";
747
856
  var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
748
- var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "local-wins";
857
+ var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
749
858
  function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
750
859
  const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
751
860
  return new Promise((resolve) => {
@@ -782,7 +891,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
782
891
  syncState: {
783
892
  firstLoadDone: syncState.firstLoadDone,
784
893
  pendingChanges: syncState.pendingChanges,
785
- lastPulled: syncState.lastPulled
894
+ lastPulled: syncState.lastPulled,
895
+ conflicts: syncState.conflicts
786
896
  }
787
897
  };
788
898
  },
@@ -791,7 +901,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
791
901
  return {
792
902
  ...state,
793
903
  syncState: {
794
- ...state.syncState,
904
+ ...state.syncState || {},
795
905
  status: "idle"
796
906
  // this confirms 'hydrating' is done
797
907
  }
@@ -799,13 +909,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
799
909
  }
800
910
  };
801
911
  const creator = (set, get, storeApi) => {
802
- let syncIntervalId;
912
+ let syncTimerStarted = false;
803
913
  async function syncOnce() {
804
- const state = get();
805
- if (!state.syncState.enabled || state.syncState.status !== "idle") return;
806
- set((state2) => ({
914
+ if (get().syncState.status !== "idle") return;
915
+ set((state) => ({
807
916
  syncState: {
808
- ...state2.syncState || {},
917
+ ...state.syncState || {},
809
918
  status: "syncing"
810
919
  }
811
920
  }));
@@ -840,24 +949,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
840
949
  logger.error(`[zync] push:error change=${change}`, err);
841
950
  }
842
951
  }
843
- set((state2) => ({
952
+ set((state) => ({
844
953
  syncState: {
845
- ...state2.syncState || {},
954
+ ...state.syncState || {},
846
955
  status: "idle",
847
956
  error: firstSyncError
848
957
  }
849
958
  }));
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
959
  }
862
960
  function setAndQueueToSync(partial) {
863
961
  if (typeof partial === "function") {
@@ -865,7 +963,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
865
963
  } else {
866
964
  set((state) => newSyncState(state, partial));
867
965
  }
868
- syncOnce();
869
966
  }
870
967
  function newSyncState(state, partial) {
871
968
  const pendingChanges = state.syncState.pendingChanges || [];
@@ -875,11 +972,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
875
972
  const changes = findChanges(current, updated);
876
973
  tryAddToPendingChanges(pendingChanges, stateKey, changes);
877
974
  });
975
+ const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
878
976
  return {
879
977
  ...partial,
880
978
  syncState: {
881
979
  ...state.syncState || {},
882
- pendingChanges
980
+ pendingChanges,
981
+ conflicts
883
982
  }
884
983
  };
885
984
  }
@@ -887,18 +986,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
887
986
  set((state) => ({
888
987
  syncState: {
889
988
  ...state.syncState || {},
890
- enabled
989
+ status: enabled ? "idle" : "disabled"
891
990
  }
892
991
  }));
893
- enableSyncTimer(enabled);
992
+ startSyncTimer(enabled);
894
993
  addVisibilityChangeListener(enabled);
895
994
  }
896
- function enableSyncTimer(enabled) {
897
- clearInterval(syncIntervalId);
898
- syncIntervalId = void 0;
899
- if (enabled) {
900
- syncIntervalId = setInterval(syncOnce, syncInterval);
901
- syncOnce();
995
+ function startSyncTimer(start) {
996
+ if (start) {
997
+ tryStart();
998
+ } else {
999
+ syncTimerStarted = false;
1000
+ }
1001
+ }
1002
+ async function tryStart() {
1003
+ if (syncTimerStarted) return;
1004
+ syncTimerStarted = true;
1005
+ while (true) {
1006
+ if (!syncTimerStarted) break;
1007
+ await syncOnce();
1008
+ await sleep(syncInterval);
902
1009
  }
903
1010
  }
904
1011
  function addVisibilityChangeListener(add) {
@@ -911,24 +1018,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
911
1018
  function onVisibilityChange() {
912
1019
  if (document.visibilityState === "visible") {
913
1020
  logger.debug("[zync] sync:start-in-foreground");
914
- enableSyncTimer(true);
1021
+ startSyncTimer(true);
915
1022
  } else {
916
1023
  logger.debug("[zync] sync:pause-in-background");
917
- enableSyncTimer(false);
1024
+ startSyncTimer(false);
918
1025
  }
919
1026
  }
920
1027
  storeApi.sync = {
921
1028
  enable,
922
- startFirstLoad: () => startFirstLoad(set, syncApi, logger)
1029
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger),
1030
+ resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
923
1031
  };
924
- const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
1032
+ const userState = stateCreator(set, get, setAndQueueToSync);
925
1033
  return {
926
1034
  ...userState,
927
1035
  syncState: {
928
1036
  // set defaults
929
1037
  status: "hydrating",
930
1038
  error: void 0,
931
- enabled: false,
1039
+ conflicts: void 0,
932
1040
  firstLoadDone: false,
933
1041
  pendingChanges: [],
934
1042
  lastPulled: {}
@@ -940,9 +1048,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
940
1048
  // Annotate the CommonJS export names for ESM import in node:
941
1049
  0 && (module.exports = {
942
1050
  SyncAction,
1051
+ changeKeysFrom,
1052
+ changeKeysTo,
943
1053
  createIndexedDBStorage,
1054
+ createLocalId,
944
1055
  createWithSync,
945
- nextLocalId,
946
1056
  persistWithSync
947
1057
  });
948
1058
  //# sourceMappingURL=index.cjs.map