@anfenn/zync 0.2.0 → 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
@@ -4,27 +4,32 @@
4
4
 
5
5
  Simple, 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.
8
+
7
9
  ## Benefits
8
10
 
9
11
  - Easy to sync non-nested array state with a backend (i.e. mirror remote database tables locally)
10
12
  - **"It just works"** philosophy
13
+ - Optimistic UI updates
11
14
  - Batteries optionally included:
12
15
  - IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
16
+ - UUID helper
13
17
  - Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
14
18
  - Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
15
19
  - Allows for idiomatic use of Zustand
16
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
17
22
  - **_Coming soon_**: Customisable conflict resolution. Currently local-wins.
18
23
 
19
24
  ## Requirements
20
25
 
21
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
22
27
  - Server records must have:
23
- - `id`: Server assigned unique identifier (any datatype)
28
+ - `id`: Any datatype, can be a server OR client assigned value
24
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.
25
30
  - `deleted`: Boolean, used for soft deletes, to allow other clients to download deleted records to keep their local records in sync
26
31
 
27
- ## Quickstart
32
+ ## Quickstart - Server assigned id example
28
33
 
29
34
  ```bash
30
35
  npm install @anfenn/zync
@@ -53,7 +58,7 @@ export const useStore = create<any>()(
53
58
 
54
59
  facts: [],
55
60
  addFact: (item: Fact) => {
56
- const updated_at = new Date().toISOString();
61
+ const updated_at = new Date().toISOString(); // Used as an optimistic UI update only, never sent to server
57
62
  const newItem = { ...item, created_at: updated_at, updated_at };
58
63
 
59
64
  setAndSync((state: Store) => ({
@@ -103,7 +108,7 @@ export const useFacts = () =>
103
108
  );
104
109
  ```
105
110
 
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
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
107
112
  `e.g. {...storeState1, ...storeState2}`
108
113
 
109
114
  ### In your component:
@@ -239,6 +244,10 @@ async function firstLoad(lastId: any) {
239
244
  }
240
245
  ```
241
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
+
242
251
  ## Optional IndexedDB storage
243
252
 
244
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;
@@ -413,9 +449,9 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
413
449
  continue;
414
450
  }
415
451
  delete remote.deleted;
416
- const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
417
452
  if (localItem) {
418
- if (pending) {
453
+ const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
454
+ if (pendingChange) {
419
455
  switch (conflictResolutionStrategy) {
420
456
  case "local-wins":
421
457
  logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
@@ -446,11 +482,10 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
446
482
  } else {
447
483
  const merged = {
448
484
  ...localItem,
449
- ...remote,
450
- _localId: localItem._localId
485
+ ...remote
451
486
  };
452
487
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
453
- logger.debug(`[zync] pull:merge stateKey=${stateKey} id=${remote.id}`);
488
+ logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
454
489
  }
455
490
  } else {
456
491
  nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
@@ -471,96 +506,85 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
471
506
  }
472
507
 
473
508
  // src/push.ts
474
- var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
475
509
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
476
510
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
477
- const { action, stateKey, localId, id, version } = change;
511
+ const { action, stateKey, localId, id, version, changes } = change;
512
+ const changesClone = { ...changes };
478
513
  switch (action) {
479
514
  case "remove" /* Remove */:
480
515
  if (!id) {
481
- logger.warn(`[zync] push:remove:no-id ${stateKey} ${localId}`);
516
+ logger.warn(`[zync] push:remove:no-id stateKey=${stateKey} localId=${localId}`);
482
517
  removeFromPendingChanges(set, localId, stateKey);
483
518
  return;
484
519
  }
485
520
  await api.remove(id);
486
- logger.debug(`[zync] push:remove:success ${stateKey} ${localId} ${id}`);
521
+ logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
487
522
  removeFromPendingChanges(set, localId, stateKey);
488
523
  break;
489
- case "create-or-update" /* CreateOrUpdate */: {
490
- const state = get();
491
- const items = state[stateKey] || [];
492
- const item = items.find((i) => i._localId === localId);
493
- if (!item) {
494
- logger.warn(`[zync] push:create-or-update:no-local-item`, {
495
- stateKey,
496
- localId
497
- });
498
- 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
+ }
499
531
  return;
500
- }
501
- const omittedItem = omitSyncFields(item, SYNC_FIELDS);
502
- if (item.id) {
503
- const changed = await api.update(item.id, omittedItem);
504
- if (changed) {
505
- logger.debug("[zync] push:update:success", {
506
- stateKey,
507
- localId,
508
- id: item.id
509
- });
510
- if (samePendingVersion(get, stateKey, localId, version)) {
511
- removeFromPendingChanges(set, localId, stateKey);
512
- }
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);
513
539
  return;
514
- } else {
515
- switch (missingStrategy) {
516
- case "delete-local-record":
517
- set((s) => ({
518
- [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
519
- }));
520
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
521
- break;
522
- case "insert-remote-record":
523
- omittedItem._localId = crypto.randomUUID();
524
- omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
525
- setAndQueueToSync((s) => ({
526
- [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
527
- }));
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}`);
535
- 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;
536
559
  }
537
- removeFromPendingChanges(set, localId, stateKey);
538
- 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;
539
566
  }
540
- return;
567
+ removeFromPendingChanges(set, localId, stateKey);
568
+ onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
541
569
  }
542
- const result = await api.add(omittedItem);
570
+ break;
571
+ }
572
+ case "create" /* Create */: {
573
+ const result = await api.add(changesClone);
543
574
  if (result) {
544
- logger.debug("[zync] push:create:success", {
545
- stateKey,
546
- localId,
547
- id: result.id
548
- });
575
+ logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
549
576
  set((s) => ({
550
577
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
551
578
  }));
552
579
  if (samePendingVersion(get, stateKey, localId, version)) {
553
580
  removeFromPendingChanges(set, localId, stateKey);
581
+ } else {
582
+ setPendingChangeToUpdate(get, stateKey, localId, result.id);
554
583
  }
555
- onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
556
- ...item,
557
- ...result
558
- });
584
+ const finalItem = { ...changes, ...result, _localId: localId };
585
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
559
586
  } else {
560
- logger.warn("[zync] push:create:no-result", {
561
- stateKey,
562
- localId
563
- });
587
+ logger.warn(`[zync] push:create:no-result stateKey=${stateKey} localId=${localId} id=${id}`);
564
588
  if (samePendingVersion(get, stateKey, localId, version)) {
565
589
  removeFromPendingChanges(set, localId, stateKey);
566
590
  }
@@ -570,6 +594,74 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
570
594
  }
571
595
  }
572
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
+
573
665
  // src/indexedDBStorage.ts
574
666
  function createIndexedDBStorage(options) {
575
667
  const dbName = options.dbName;
@@ -644,7 +736,8 @@ function createIndexedDBStorage(options) {
644
736
 
645
737
  // src/index.ts
646
738
  var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
647
- SyncAction2["CreateOrUpdate"] = "create-or-update";
739
+ SyncAction2["Create"] = "create";
740
+ SyncAction2["Update"] = "update";
648
741
  SyncAction2["Remove"] = "remove";
649
742
  return SyncAction2;
650
743
  })(SyncAction || {});
@@ -716,13 +809,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
716
809
  status: "syncing"
717
810
  }
718
811
  }));
719
- let syncError;
812
+ let firstSyncError;
720
813
  for (const stateKey of Object.keys(syncApi)) {
721
814
  try {
722
815
  const api = findApi(stateKey, syncApi);
723
816
  await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
724
817
  } catch (err) {
725
- syncError = syncError ?? err;
818
+ firstSyncError = firstSyncError ?? err;
726
819
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
727
820
  }
728
821
  }
@@ -743,7 +836,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
743
836
  syncOptions.onAfterRemoteAdd
744
837
  );
745
838
  } catch (err) {
746
- syncError = syncError ?? err;
839
+ firstSyncError = firstSyncError ?? err;
747
840
  logger.error(`[zync] push:error change=${change}`, err);
748
841
  }
749
842
  }
@@ -751,76 +844,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
751
844
  syncState: {
752
845
  ...state2.syncState || {},
753
846
  status: "idle",
754
- error: syncError
847
+ error: firstSyncError
755
848
  }
756
849
  }));
757
- if (get().syncState.pendingChanges.length > 0 && !syncError) {
850
+ if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
758
851
  await syncOnce();
759
852
  }
760
853
  }
761
- async function startFirstLoad() {
762
- let syncError;
763
- for (const stateKey of Object.keys(syncApi)) {
764
- try {
765
- logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
766
- const api = findApi(stateKey, syncApi);
767
- let lastId;
768
- while (true) {
769
- const batch = await api.firstLoad(lastId);
770
- if (!batch?.length) break;
771
- set((state) => {
772
- const local = state[stateKey] || [];
773
- const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
774
- let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
775
- const next = [...local];
776
- for (const remote of batch) {
777
- const remoteUpdated = new Date(remote.updated_at || 0);
778
- if (remoteUpdated > newest) newest = remoteUpdated;
779
- if (remote.deleted) continue;
780
- delete remote.deleted;
781
- const localItem = remote.id ? localById.get(remote.id) : void 0;
782
- if (localItem) {
783
- const merged = {
784
- ...localItem,
785
- ...remote,
786
- _localId: localItem._localId
787
- };
788
- const idx = next.findIndex((i) => i._localId === localItem._localId);
789
- if (idx >= 0) next[idx] = merged;
790
- } else {
791
- next.push({
792
- ...remote,
793
- _localId: nextLocalId()
794
- });
795
- }
796
- }
797
- return {
798
- [stateKey]: next,
799
- syncState: {
800
- ...state.syncState || {},
801
- lastPulled: {
802
- ...state.syncState.lastPulled || {},
803
- [stateKey]: newest.toISOString()
804
- }
805
- }
806
- };
807
- });
808
- lastId = batch[batch.length - 1].id;
809
- }
810
- logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
811
- } catch (err) {
812
- syncError = syncError ?? err;
813
- logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
814
- }
815
- }
816
- set((state) => ({
817
- syncState: {
818
- ...state.syncState || {},
819
- firstLoadDone: true,
820
- error: syncError
821
- }
822
- }));
823
- }
824
854
  function setAndSyncOnce(partial) {
825
855
  if (typeof partial === "function") {
826
856
  set((state) => ({ ...partial(state) }));
@@ -843,7 +873,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
843
873
  const current = state[stateKey];
844
874
  const updated = partial[stateKey];
845
875
  const changes = findChanges(current, updated);
846
- addToPendingChanges(pendingChanges, stateKey, changes);
876
+ tryAddToPendingChanges(pendingChanges, stateKey, changes);
847
877
  });
848
878
  return {
849
879
  ...partial,
@@ -853,25 +883,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
853
883
  }
854
884
  };
855
885
  }
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
- }
875
886
  function enable(enabled) {
876
887
  set((state) => ({
877
888
  syncState: {
@@ -908,7 +919,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
908
919
  }
909
920
  storeApi.sync = {
910
921
  enable,
911
- startFirstLoad
922
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger)
912
923
  };
913
924
  const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
914
925
  return {