@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 +11 -4
- package/dist/index.cjs +178 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +178 -174
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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`:
|
|
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
|
|
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
|
|
319
|
+
case "create" /* Create */:
|
|
319
320
|
return 1;
|
|
320
|
-
case "
|
|
321
|
+
case "update" /* Update */:
|
|
321
322
|
return 2;
|
|
323
|
+
case "remove" /* Remove */:
|
|
324
|
+
return 3;
|
|
322
325
|
}
|
|
323
326
|
}
|
|
324
|
-
function omitSyncFields(item
|
|
327
|
+
function omitSyncFields(item) {
|
|
325
328
|
const result = { ...item };
|
|
326
|
-
for (const k of
|
|
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
|
|
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
|
|
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
|
|
521
|
+
logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
494
522
|
removeFromPendingChanges(set, localId, stateKey);
|
|
495
523
|
break;
|
|
496
|
-
case "
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
511
|
-
if (
|
|
512
|
-
logger.
|
|
513
|
-
|
|
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
|
-
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
567
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
568
|
+
onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
|
|
548
569
|
}
|
|
549
|
-
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "create" /* Create */: {
|
|
573
|
+
const result = await api.add(changesClone);
|
|
550
574
|
if (result) {
|
|
551
|
-
logger.debug(
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
...result
|
|
565
|
-
});
|
|
584
|
+
const finalItem = { ...changes, ...result, _localId: localId };
|
|
585
|
+
onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
|
|
566
586
|
} else {
|
|
567
|
-
logger.warn(
|
|
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["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
847
|
+
error: firstSyncError
|
|
762
848
|
}
|
|
763
849
|
}));
|
|
764
|
-
if (get().syncState.pendingChanges.length > 0 && !
|
|
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
|
-
|
|
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 {
|