@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 +13 -4
- package/dist/index.cjs +181 -170
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +181 -170
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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`:
|
|
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
|
|
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
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
521
|
+
logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
487
522
|
removeFromPendingChanges(set, localId, stateKey);
|
|
488
523
|
break;
|
|
489
|
-
case "
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
if (
|
|
505
|
-
logger.
|
|
506
|
-
|
|
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
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
567
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
568
|
+
onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
|
|
541
569
|
}
|
|
542
|
-
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
case "create" /* Create */: {
|
|
573
|
+
const result = await api.add(changesClone);
|
|
543
574
|
if (result) {
|
|
544
|
-
logger.debug(
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
...result
|
|
558
|
-
});
|
|
584
|
+
const finalItem = { ...changes, ...result, _localId: localId };
|
|
585
|
+
onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
|
|
559
586
|
} else {
|
|
560
|
-
logger.warn(
|
|
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["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
847
|
+
error: firstSyncError
|
|
755
848
|
}
|
|
756
849
|
}));
|
|
757
|
-
if (get().syncState.pendingChanges.length > 0 && !
|
|
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
|
-
|
|
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 {
|