@anfenn/zync 0.2.1 → 0.3.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
@@ -10,23 +10,26 @@ Simple, bullet-proof, offline-first sync middleware for Zustand.
10
10
 
11
11
  - Easy to sync non-nested array state with a backend (i.e. mirror remote database tables locally)
12
12
  - **"It just works"** philosophy
13
+ - Optimistic UI updates
13
14
  - Batteries optionally included:
14
15
  - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
16
+ - UUID helper
15
17
  - Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
16
18
  - Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
17
19
  - Allows for idiomatic use of Zustand
18
20
  - Leaves the api requests up to you (RESTful, GraphQL, etc.), just provide add(), update(), remove() and list()
21
+ - Client or server assigned primary key, of any datatype
19
22
  - **_Coming soon_**: Customisable conflict resolution. Currently local-wins.
20
23
 
21
24
  ## Requirements
22
25
 
23
26
  - 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
24
27
  - Server records must have:
25
- - `id`: Server assigned unique identifier (any datatype)
28
+ - `id`: Any datatype, can be a server OR client assigned value
26
29
  - `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.
27
30
  - `deleted`: Boolean, used for soft deletes, to allow other clients to download deleted records to keep their local records in sync
28
31
 
29
- ## Quickstart
32
+ ## Quickstart - Server assigned id example
30
33
 
31
34
  ```bash
32
35
  npm install @anfenn/zync
@@ -55,7 +58,7 @@ export const useStore = create<any>()(
55
58
 
56
59
  facts: [],
57
60
  addFact: (item: Fact) => {
58
- const updated_at = new Date().toISOString();
61
+ const updated_at = new Date().toISOString(); // Used as an optimistic UI update only, never sent to server
59
62
  const newItem = { ...item, created_at: updated_at, updated_at };
60
63
 
61
64
  setAndSync((state: Store) => ({
@@ -105,7 +108,7 @@ export const useFacts = () =>
105
108
  );
106
109
  ```
107
110
 
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
111
+ **_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 them with object spreading
109
112
  `e.g. {...storeState1, ...storeState2}`
110
113
 
111
114
  ### In your component:
@@ -241,6 +244,10 @@ async function firstLoad(lastId: any) {
241
244
  }
242
245
  ```
243
246
 
247
+ ## Client side assigned id
248
+
249
+ As simple as just setting the id field when creating a new record (and amend model types if using Typescript)
250
+
244
251
  ## Optional IndexedDB storage
245
252
 
246
253
  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.
package/dist/index.cjs CHANGED
@@ -310,20 +310,23 @@ function newLogger(base, min) {
310
310
  }
311
311
 
312
312
  // src/helpers.ts
313
+ var SYNC_FIELDS = ["_localId", "updated_at", "deleted"];
313
314
  function nextLocalId() {
314
315
  return crypto.randomUUID();
315
316
  }
316
317
  function orderFor(a) {
317
318
  switch (a) {
318
- case "create-or-update" /* CreateOrUpdate */:
319
+ case "create" /* Create */:
319
320
  return 1;
320
- case "remove" /* Remove */:
321
+ case "update" /* Update */:
321
322
  return 2;
323
+ case "remove" /* Remove */:
324
+ return 3;
322
325
  }
323
326
  }
324
- function omitSyncFields(item, fields) {
327
+ function omitSyncFields(item) {
325
328
  const result = { ...item };
326
- for (const k of fields) delete result[k];
329
+ for (const k of SYNC_FIELDS) delete result[k];
327
330
  return result;
328
331
  }
329
332
  function samePendingVersion(get, stateKey, localId, version) {
@@ -342,6 +345,38 @@ function removeFromPendingChanges(set, localId, stateKey) {
342
345
  };
343
346
  });
344
347
  }
348
+ function tryAddToPendingChanges(pendingChanges, stateKey, changes) {
349
+ for (const [localId, change] of changes) {
350
+ let omittedItem = omitSyncFields(change.changes);
351
+ const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
352
+ const hasChanges = Object.keys(omittedItem).length > 0;
353
+ const action = change.updatedItem === null ? "remove" /* Remove */ : change.currentItem === null ? "create" /* Create */ : "update" /* Update */;
354
+ if (action === "update" /* Update */ && change.updatedItem && change.currentItem && change.currentItem._localId !== change.updatedItem._localId) {
355
+ omittedItem = omitSyncFields(change.updatedItem);
356
+ }
357
+ if (queueItem) {
358
+ if (queueItem.action === "remove" /* Remove */) {
359
+ continue;
360
+ }
361
+ queueItem.version += 1;
362
+ if (action === "remove" /* Remove */) {
363
+ queueItem.action = "remove" /* Remove */;
364
+ } else if (hasChanges) {
365
+ queueItem.changes = { ...queueItem.changes, ...omittedItem };
366
+ }
367
+ } else if (action === "remove" /* Remove */ || hasChanges) {
368
+ pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem });
369
+ }
370
+ }
371
+ }
372
+ function setPendingChangeToUpdate(get, stateKey, localId, id) {
373
+ const pendingChanges = get().syncState.pendingChanges || [];
374
+ const change = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
375
+ if (change) {
376
+ change.action = "update" /* Update */;
377
+ if (id) change.id = id;
378
+ }
379
+ }
345
380
  function findApi(stateKey, syncApi) {
346
381
  const api = syncApi[stateKey];
347
382
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -353,11 +388,12 @@ function findChanges(current, updated) {
353
388
  const currentMap = /* @__PURE__ */ new Map();
354
389
  for (const item of current) {
355
390
  if (item && item._localId) {
356
- currentMap.set(item._localId, item);
391
+ currentMap.set(item._localId, { ...item });
357
392
  }
358
393
  }
359
394
  const changesMap = /* @__PURE__ */ new Map();
360
- for (const item of updated) {
395
+ for (const update of updated) {
396
+ const item = { ...update };
361
397
  if (item && item._localId) {
362
398
  const curr = currentMap.get(item._localId);
363
399
  if (curr) {
@@ -368,16 +404,16 @@ function findChanges(current, updated) {
368
404
  }
369
405
  }
370
406
  if (Object.keys(diff).length > 0) {
371
- changesMap.set(item._localId, { currentItem: curr, updatedItem: item, changes: diff });
407
+ changesMap.set(item._localId, { currentItem: curr, updatedItem: item, changes: diff, id: curr.id ?? item.id });
372
408
  }
373
409
  } else {
374
- changesMap.set(item._localId, { currentItem: null, updatedItem: item, changes: item });
410
+ changesMap.set(item._localId, { currentItem: null, updatedItem: item, changes: item, id: item.id });
375
411
  }
376
412
  }
377
413
  }
378
414
  for (const [localId, curr] of currentMap) {
379
415
  if (!updated.some((u) => u && u._localId === localId)) {
380
- changesMap.set(localId, { currentItem: curr, updatedItem: null, changes: null });
416
+ changesMap.set(localId, { currentItem: curr, updatedItem: null, changes: null, id: curr.id });
381
417
  }
382
418
  }
383
419
  return changesMap;
@@ -432,13 +468,6 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
432
468
  // case 'try-shallow-merge':
433
469
  // // Try and merge all fields, fail if not possible due to conflicts
434
470
  // // 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
471
  // break;
443
472
  // case 'custom':
444
473
  // // Hook to allow custom userland logic
@@ -453,8 +482,7 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
453
482
  } else {
454
483
  const merged = {
455
484
  ...localItem,
456
- ...remote,
457
- _localId: localItem._localId
485
+ ...remote
458
486
  };
459
487
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
460
488
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
@@ -478,96 +506,85 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
478
506
  }
479
507
 
480
508
  // src/push.ts
481
- var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
482
509
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
483
510
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
484
- const { action, stateKey, localId, id, version } = change;
511
+ const { action, stateKey, localId, id, version, changes } = change;
512
+ const changesClone = { ...changes };
485
513
  switch (action) {
486
514
  case "remove" /* Remove */:
487
515
  if (!id) {
488
- logger.warn(`[zync] push:remove:no-id ${stateKey} ${localId}`);
516
+ logger.warn(`[zync] push:remove:no-id stateKey=${stateKey} localId=${localId}`);
489
517
  removeFromPendingChanges(set, localId, stateKey);
490
518
  return;
491
519
  }
492
520
  await api.remove(id);
493
- logger.debug(`[zync] push:remove:success ${stateKey} ${localId} ${id}`);
521
+ logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
494
522
  removeFromPendingChanges(set, localId, stateKey);
495
523
  break;
496
- case "create-or-update" /* CreateOrUpdate */: {
497
- const state = get();
498
- const items = state[stateKey] || [];
499
- const item = items.find((i) => i._localId === localId);
500
- if (!item) {
501
- logger.warn(`[zync] push:create-or-update:no-local-item`, {
502
- stateKey,
503
- localId
504
- });
505
- removeFromPendingChanges(set, localId, stateKey);
524
+ case "update" /* Update */: {
525
+ const changed = await api.update(id, changesClone);
526
+ if (changed) {
527
+ logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
528
+ if (samePendingVersion(get, stateKey, localId, version)) {
529
+ removeFromPendingChanges(set, localId, stateKey);
530
+ }
506
531
  return;
507
- }
508
- const omittedItem = omitSyncFields(item, SYNC_FIELDS);
509
- if (item.id) {
510
- const changed = await api.update(item.id, omittedItem);
511
- if (changed) {
512
- logger.debug("[zync] push:update:success", {
513
- stateKey,
514
- localId,
515
- id: item.id
516
- });
517
- if (samePendingVersion(get, stateKey, localId, version)) {
518
- removeFromPendingChanges(set, localId, stateKey);
519
- }
532
+ } else {
533
+ const state = get();
534
+ const items = state[stateKey] || [];
535
+ const item = items.find((i) => i._localId === localId);
536
+ if (!item) {
537
+ logger.warn(`[zync] push:missing-remote:no-local-item stateKey=${stateKey} localId=${localId}`);
538
+ removeFromPendingChanges(set, localId, stateKey);
520
539
  return;
521
- } else {
522
- switch (missingStrategy) {
523
- case "delete-local-record":
524
- set((s) => ({
525
- [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
526
- }));
527
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
528
- break;
529
- case "insert-remote-record":
530
- omittedItem._localId = crypto.randomUUID();
531
- omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
532
- setAndQueueToSync((s) => ({
533
- [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
534
- }));
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}`);
542
- break;
540
+ }
541
+ switch (missingStrategy) {
542
+ case "delete-local-record":
543
+ set((s) => ({
544
+ [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
545
+ }));
546
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
547
+ break;
548
+ case "insert-remote-record": {
549
+ const newItem = {
550
+ ...item,
551
+ _localId: nextLocalId(),
552
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
553
+ };
554
+ setAndQueueToSync((s) => ({
555
+ [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? newItem : i)
556
+ }));
557
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${newItem.id}`);
558
+ break;
543
559
  }
544
- removeFromPendingChanges(set, localId, stateKey);
545
- onMissingRemoteRecordDuringUpdate?.(missingStrategy, item, omittedItem._localId);
560
+ case "ignore":
561
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
562
+ break;
563
+ default:
564
+ logger.error(`[zync] push:missing-remote:unknown-strategy stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
565
+ break;
546
566
  }
547
- return;
567
+ removeFromPendingChanges(set, localId, stateKey);
568
+ onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
548
569
  }
549
- const result = await api.add(omittedItem);
570
+ break;
571
+ }
572
+ case "create" /* Create */: {
573
+ const result = await api.add(changesClone);
550
574
  if (result) {
551
- logger.debug("[zync] push:create:success", {
552
- stateKey,
553
- localId,
554
- id: result.id
555
- });
575
+ logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
556
576
  set((s) => ({
557
577
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
558
578
  }));
559
579
  if (samePendingVersion(get, stateKey, localId, version)) {
560
580
  removeFromPendingChanges(set, localId, stateKey);
581
+ } else {
582
+ setPendingChangeToUpdate(get, stateKey, localId, result.id);
561
583
  }
562
- onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
563
- ...item,
564
- ...result
565
- });
584
+ const finalItem = { ...changes, ...result, _localId: localId };
585
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
566
586
  } else {
567
- logger.warn("[zync] push:create:no-result", {
568
- stateKey,
569
- localId
570
- });
587
+ logger.warn(`[zync] push:create:no-result stateKey=${stateKey} localId=${localId} id=${id}`);
571
588
  if (samePendingVersion(get, stateKey, localId, version)) {
572
589
  removeFromPendingChanges(set, localId, stateKey);
573
590
  }
@@ -577,6 +594,74 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
577
594
  }
578
595
  }
579
596
 
597
+ // src/firstLoad.ts
598
+ async function startFirstLoad(set, syncApi, logger) {
599
+ let syncError;
600
+ for (const stateKey of Object.keys(syncApi)) {
601
+ try {
602
+ logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
603
+ const api = findApi(stateKey, syncApi);
604
+ let lastId;
605
+ while (true) {
606
+ const batch = await api.firstLoad(lastId);
607
+ if (!batch?.length) break;
608
+ set((state) => {
609
+ const local = state[stateKey] || [];
610
+ const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
611
+ let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
612
+ const next = [...local];
613
+ for (const remote of batch) {
614
+ const remoteUpdated = new Date(remote.updated_at || 0);
615
+ if (remoteUpdated > newest) newest = remoteUpdated;
616
+ if (remote.deleted) continue;
617
+ delete remote.deleted;
618
+ const localItem = remote.id ? localById.get(remote.id) : void 0;
619
+ if (localItem) {
620
+ const merged = {
621
+ ...localItem,
622
+ ...remote,
623
+ _localId: localItem._localId
624
+ };
625
+ const idx = next.findIndex((i) => i._localId === localItem._localId);
626
+ if (idx >= 0) next[idx] = merged;
627
+ } else {
628
+ next.push({
629
+ ...remote,
630
+ _localId: nextLocalId()
631
+ });
632
+ }
633
+ }
634
+ return {
635
+ [stateKey]: next,
636
+ syncState: {
637
+ ...state.syncState || {},
638
+ lastPulled: {
639
+ ...state.syncState.lastPulled || {},
640
+ [stateKey]: newest.toISOString()
641
+ }
642
+ }
643
+ };
644
+ });
645
+ if (lastId !== void 0 && lastId === batch[batch.length - 1].id) {
646
+ throw new Error(`Duplicate records downloaded, stopping to prevent infinite loop`);
647
+ }
648
+ lastId = batch[batch.length - 1].id;
649
+ }
650
+ logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
651
+ } catch (err) {
652
+ syncError = syncError ?? err;
653
+ logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
654
+ }
655
+ }
656
+ set((state) => ({
657
+ syncState: {
658
+ ...state.syncState || {},
659
+ firstLoadDone: true,
660
+ error: syncError
661
+ }
662
+ }));
663
+ }
664
+
580
665
  // src/indexedDBStorage.ts
581
666
  function createIndexedDBStorage(options) {
582
667
  const dbName = options.dbName;
@@ -651,7 +736,8 @@ function createIndexedDBStorage(options) {
651
736
 
652
737
  // src/index.ts
653
738
  var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
654
- SyncAction2["CreateOrUpdate"] = "create-or-update";
739
+ SyncAction2["Create"] = "create";
740
+ SyncAction2["Update"] = "update";
655
741
  SyncAction2["Remove"] = "remove";
656
742
  return SyncAction2;
657
743
  })(SyncAction || {});
@@ -723,13 +809,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
723
809
  status: "syncing"
724
810
  }
725
811
  }));
726
- let syncError;
812
+ let firstSyncError;
727
813
  for (const stateKey of Object.keys(syncApi)) {
728
814
  try {
729
815
  const api = findApi(stateKey, syncApi);
730
816
  await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
731
817
  } catch (err) {
732
- syncError = syncError ?? err;
818
+ firstSyncError = firstSyncError ?? err;
733
819
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
734
820
  }
735
821
  }
@@ -750,7 +836,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
750
836
  syncOptions.onAfterRemoteAdd
751
837
  );
752
838
  } catch (err) {
753
- syncError = syncError ?? err;
839
+ firstSyncError = firstSyncError ?? err;
754
840
  logger.error(`[zync] push:error change=${change}`, err);
755
841
  }
756
842
  }
@@ -758,76 +844,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
758
844
  syncState: {
759
845
  ...state2.syncState || {},
760
846
  status: "idle",
761
- error: syncError
847
+ error: firstSyncError
762
848
  }
763
849
  }));
764
- if (get().syncState.pendingChanges.length > 0 && !syncError) {
850
+ if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
765
851
  await syncOnce();
766
852
  }
767
853
  }
768
- async function startFirstLoad() {
769
- let syncError;
770
- for (const stateKey of Object.keys(syncApi)) {
771
- try {
772
- logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
773
- const api = findApi(stateKey, syncApi);
774
- let lastId;
775
- while (true) {
776
- const batch = await api.firstLoad(lastId);
777
- if (!batch?.length) break;
778
- set((state) => {
779
- const local = state[stateKey] || [];
780
- const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
781
- let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
782
- const next = [...local];
783
- for (const remote of batch) {
784
- const remoteUpdated = new Date(remote.updated_at || 0);
785
- if (remoteUpdated > newest) newest = remoteUpdated;
786
- if (remote.deleted) continue;
787
- delete remote.deleted;
788
- const localItem = remote.id ? localById.get(remote.id) : void 0;
789
- if (localItem) {
790
- const merged = {
791
- ...localItem,
792
- ...remote,
793
- _localId: localItem._localId
794
- };
795
- const idx = next.findIndex((i) => i._localId === localItem._localId);
796
- if (idx >= 0) next[idx] = merged;
797
- } else {
798
- next.push({
799
- ...remote,
800
- _localId: nextLocalId()
801
- });
802
- }
803
- }
804
- return {
805
- [stateKey]: next,
806
- syncState: {
807
- ...state.syncState || {},
808
- lastPulled: {
809
- ...state.syncState.lastPulled || {},
810
- [stateKey]: newest.toISOString()
811
- }
812
- }
813
- };
814
- });
815
- lastId = batch[batch.length - 1].id;
816
- }
817
- logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
818
- } catch (err) {
819
- syncError = syncError ?? err;
820
- logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
821
- }
822
- }
823
- set((state) => ({
824
- syncState: {
825
- ...state.syncState || {},
826
- firstLoadDone: true,
827
- error: syncError
828
- }
829
- }));
830
- }
831
854
  function setAndSyncOnce(partial) {
832
855
  if (typeof partial === "function") {
833
856
  set((state) => ({ ...partial(state) }));
@@ -850,7 +873,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
850
873
  const current = state[stateKey];
851
874
  const updated = partial[stateKey];
852
875
  const changes = findChanges(current, updated);
853
- addToPendingChanges(pendingChanges, stateKey, changes);
876
+ tryAddToPendingChanges(pendingChanges, stateKey, changes);
854
877
  });
855
878
  return {
856
879
  ...partial,
@@ -860,25 +883,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
860
883
  }
861
884
  };
862
885
  }
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
- }
882
886
  function enable(enabled) {
883
887
  set((state) => ({
884
888
  syncState: {
@@ -915,7 +919,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
915
919
  }
916
920
  storeApi.sync = {
917
921
  enable,
918
- startFirstLoad
922
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger)
919
923
  };
920
924
  const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
921
925
  return {