@anfenn/zync 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -20
- package/dist/index.cjs +189 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +19 -6
- package/dist/index.d.ts +19 -6
- package/dist/index.js +186 -78
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Simple, unopinionated, bullet-proof, offline-first sync middleware for Zustand.
|
|
6
6
|
|
|
7
|
-
**_STATUS_**: Actively developed in
|
|
7
|
+
**_STATUS_**: Actively developed in beta stage. Core features are complete, Api is stable, requests are welcome.
|
|
8
8
|
|
|
9
9
|
## Benefits
|
|
10
10
|
|
|
@@ -12,33 +12,38 @@ Simple, unopinionated, bullet-proof, offline-first sync middleware for Zustand.
|
|
|
12
12
|
- **"It just works"** philosophy
|
|
13
13
|
- Optimistic UI updates
|
|
14
14
|
- Conflict resolution:
|
|
15
|
-
- '
|
|
15
|
+
- 'client-wins' | 'server-wins' | 'try-shallow-merge'
|
|
16
|
+
- 'try-shallow-merge' allows the user to choose between client and server changes if conflicts are found
|
|
16
17
|
- Missing remote record during update strategy, to prevent accidental server deletion from losing client data:
|
|
17
18
|
- 'ignore' | 'delete-local-record' | 'insert-remote-record'
|
|
18
19
|
- Batteries optionally included:
|
|
19
|
-
- IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb))
|
|
20
|
-
- UUID helper
|
|
20
|
+
- IndexedDB helper (based on [idb](https://www.npmjs.com/package/idb)): `createIndexedDBStorage()`
|
|
21
|
+
- UUID helper: `createLocalId()`
|
|
22
|
+
- Object|Array key rename helpers to map endpoint fields to Zync: `changeKeysFrom()` & `changeKeysTo()`
|
|
21
23
|
- Uses the official persist middleware as the local storage (localStorage, IndexedDB, etc.)
|
|
22
24
|
- Zync's persistWithSync() is a drop-in replacement for Zustand's persist()
|
|
23
25
|
- Allows for idiomatic use of Zustand
|
|
24
26
|
- Leaves the api requests up to you (RESTful, GraphQL, etc.), just provide add(), update(), remove() and list()
|
|
25
27
|
- Client or server assigned primary key, of any datatype
|
|
28
|
+
- Fully tested on `localstorage` and `IndexedDB` (>80% code coverage)
|
|
29
|
+
- Client schema migrations are a breeze using Zustand's [migrate](https://zustand.docs.pmnd.rs/middlewares/persist#persisting-a-state-through-versioning-and-migrations) hook
|
|
30
|
+
- All Zync's internal state is accessible via the reactive `state.syncState` object
|
|
26
31
|
|
|
27
32
|
## Requirements
|
|
28
33
|
|
|
29
|
-
- Client records will have a `_localId` field which is stable and never sent to the server. It is ideal for use as a key in JSX. The provided helper function `
|
|
34
|
+
- Client records will have a `_localId` field which is stable and never sent to the server. It is ideal for use as a key in JSX. The provided helper function `createLocalId()` returns a UUID, but you could use any unique value
|
|
30
35
|
- Server records must have:
|
|
31
36
|
|
|
32
37
|
- `id`: Any datatype, can be a server OR client assigned value
|
|
33
38
|
- `updated_at`: Server assigned **_millisecond_** timestamp (db trigger or api layer). The client will never send this as the client clock is unlikely to be in sync with the server, so is never used for change detection. If it has a higher precision than millisecond, like PostgreSQL's microsecond timestampz, updates could be ignored.
|
|
34
39
|
- `deleted`: Boolean, used for soft deletes, to allow other clients to download deleted records to keep their local records in sync
|
|
35
40
|
|
|
36
|
-
**_TIP: If your endpoint doesn't have the same names as the 3 fields above, you can
|
|
41
|
+
**_TIP: If your endpoint doesn't have the same names as the 3 fields above, you can easily rename them in your `api.ts` file using the included `changeKeysFrom()` & `changeKeysTo()`_**
|
|
37
42
|
|
|
38
43
|
## Quickstart
|
|
39
44
|
|
|
40
45
|
```bash
|
|
41
|
-
npm install @anfenn/zync
|
|
46
|
+
npm install zustand @anfenn/zync
|
|
42
47
|
```
|
|
43
48
|
|
|
44
49
|
_The example below uses server assigned id's, but you can just set the id when creating an object for client assigned id's._
|
|
@@ -66,7 +71,7 @@ export const useStore = create<any>()(
|
|
|
66
71
|
|
|
67
72
|
facts: [],
|
|
68
73
|
addFact: (item: Fact) => {
|
|
69
|
-
const updated_at = new Date().toISOString(); //
|
|
74
|
+
const updated_at = new Date().toISOString(); // Optimistic UI update only, never sent to server
|
|
70
75
|
const newItem = { ...item, created_at: updated_at, updated_at };
|
|
71
76
|
|
|
72
77
|
setAndSync((state: Store) => ({
|
|
@@ -108,9 +113,9 @@ export const useStore = create<any>()(
|
|
|
108
113
|
// Triggered by the api.update() returning true or false confirming the existence of the remote record after an update
|
|
109
114
|
missingRemoteRecordDuringUpdateStrategy: 'ignore',
|
|
110
115
|
|
|
111
|
-
// Options: '
|
|
112
|
-
// Default: '
|
|
113
|
-
conflictResolutionStrategy: '
|
|
116
|
+
// Options: 'client-wins' | 'server-wins' | 'try-shallow-merge'
|
|
117
|
+
// Default: 'try-shallow-merge' (Conflicts are listed in syncState.conflicts)
|
|
118
|
+
conflictResolutionStrategy: 'try-shallow-merge',
|
|
114
119
|
},
|
|
115
120
|
),
|
|
116
121
|
) as UseStoreWithSync<Store>;
|
|
@@ -133,7 +138,7 @@ export const useFacts = () =>
|
|
|
133
138
|
|
|
134
139
|
```ts
|
|
135
140
|
import { useEffect } from 'react';
|
|
136
|
-
import {
|
|
141
|
+
import { createLocalId } from '@anfenn/zync';
|
|
137
142
|
import { useFacts, useStore } from './store';
|
|
138
143
|
|
|
139
144
|
function App() {
|
|
@@ -142,17 +147,18 @@ function App() {
|
|
|
142
147
|
|
|
143
148
|
// Zync's internal sync state
|
|
144
149
|
const syncState = useStore((state) => state.syncState);
|
|
145
|
-
// syncState.status // 'hydrating' | 'syncing' | 'idle'
|
|
150
|
+
// syncState.status // 'disabled' | 'hydrating' | 'syncing' | 'idle'
|
|
146
151
|
// syncState.error
|
|
147
|
-
// syncState.
|
|
152
|
+
// syncState.conflicts
|
|
148
153
|
// syncState.firstLoadDone
|
|
149
154
|
// syncState.pendingChanges
|
|
150
155
|
// syncState.lastPulled
|
|
151
156
|
|
|
152
157
|
useEffect(() => {
|
|
153
158
|
// Zync's control api
|
|
154
|
-
useStore.sync.enable(true);
|
|
155
|
-
//useStore.sync.startFirstLoad();
|
|
159
|
+
useStore.sync.enable(true); // Defaults to false, enable to start syncing
|
|
160
|
+
//useStore.sync.startFirstLoad(); // Batch loads from server
|
|
161
|
+
//useStore.sync.resolveConflict(localId, true); // Keep client or server changes for specific record
|
|
156
162
|
}, []);
|
|
157
163
|
|
|
158
164
|
return (
|
|
@@ -161,7 +167,7 @@ function App() {
|
|
|
161
167
|
<button
|
|
162
168
|
onClick={() =>
|
|
163
169
|
addFact({
|
|
164
|
-
_localId:
|
|
170
|
+
_localId: createLocalId(),
|
|
165
171
|
title: 'New fact ' + Date.now(),
|
|
166
172
|
})
|
|
167
173
|
}
|
|
@@ -189,9 +195,8 @@ import { supabase } from './supabase'; // Please include your own :)
|
|
|
189
195
|
export type Fact = {
|
|
190
196
|
_localId: string;
|
|
191
197
|
fact: string;
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
updated_at?: string;
|
|
198
|
+
id?: number; // Client OR server assigned
|
|
199
|
+
updated_at?: string; // Server assigned
|
|
195
200
|
};
|
|
196
201
|
|
|
197
202
|
export const factApi: ApiFunctions = { add, update, remove, list, firstLoad };
|
package/dist/index.cjs
CHANGED
|
@@ -281,9 +281,11 @@ var init_build = __esm({
|
|
|
281
281
|
var index_exports = {};
|
|
282
282
|
__export(index_exports, {
|
|
283
283
|
SyncAction: () => SyncAction,
|
|
284
|
+
changeKeysFrom: () => changeKeysFrom,
|
|
285
|
+
changeKeysTo: () => changeKeysTo,
|
|
284
286
|
createIndexedDBStorage: () => createIndexedDBStorage,
|
|
287
|
+
createLocalId: () => createLocalId,
|
|
285
288
|
createWithSync: () => createWithSync,
|
|
286
|
-
nextLocalId: () => nextLocalId,
|
|
287
289
|
persistWithSync: () => persistWithSync
|
|
288
290
|
});
|
|
289
291
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -311,9 +313,40 @@ function newLogger(base, min) {
|
|
|
311
313
|
|
|
312
314
|
// src/helpers.ts
|
|
313
315
|
var SYNC_FIELDS = ["_localId", "updated_at", "deleted"];
|
|
314
|
-
function
|
|
316
|
+
function createLocalId() {
|
|
315
317
|
return crypto.randomUUID();
|
|
316
318
|
}
|
|
319
|
+
function sleep(ms) {
|
|
320
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
321
|
+
}
|
|
322
|
+
function changeKeysTo(input, toIdKey, toUpdatedAtKey, toDeletedKey) {
|
|
323
|
+
if (!input) return input;
|
|
324
|
+
const isArray = Array.isArray(input);
|
|
325
|
+
const result = (isArray ? input : [input]).map((item) => {
|
|
326
|
+
const { id, updated_at, deleted, ...rest } = item;
|
|
327
|
+
return {
|
|
328
|
+
[toIdKey]: id,
|
|
329
|
+
[toUpdatedAtKey]: updated_at,
|
|
330
|
+
[toDeletedKey]: deleted,
|
|
331
|
+
...rest
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
return isArray ? result : result[0];
|
|
335
|
+
}
|
|
336
|
+
function changeKeysFrom(input, fromIdKey, fromUpdatedAtKey, fromDeletedKey) {
|
|
337
|
+
if (!input) return input;
|
|
338
|
+
const isArray = Array.isArray(input);
|
|
339
|
+
const result = (isArray ? input : [input]).map((item) => {
|
|
340
|
+
const { [fromIdKey]: id, [fromUpdatedAtKey]: updated_at, [fromDeletedKey]: deleted, ...rest } = item;
|
|
341
|
+
return {
|
|
342
|
+
id,
|
|
343
|
+
updated_at,
|
|
344
|
+
deleted,
|
|
345
|
+
...rest
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
return isArray ? result : result[0];
|
|
349
|
+
}
|
|
317
350
|
function orderFor(a) {
|
|
318
351
|
switch (a) {
|
|
319
352
|
case "create" /* Create */:
|
|
@@ -330,8 +363,8 @@ function omitSyncFields(item) {
|
|
|
330
363
|
return result;
|
|
331
364
|
}
|
|
332
365
|
function samePendingVersion(get, stateKey, localId, version) {
|
|
333
|
-
const
|
|
334
|
-
const curChange =
|
|
366
|
+
const pending = get().syncState.pendingChanges || [];
|
|
367
|
+
const curChange = pending.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
335
368
|
return curChange?.version === version;
|
|
336
369
|
}
|
|
337
370
|
function removeFromPendingChanges(set, localId, stateKey) {
|
|
@@ -365,7 +398,7 @@ function tryAddToPendingChanges(pendingChanges, stateKey, changes) {
|
|
|
365
398
|
queueItem.changes = { ...queueItem.changes, ...omittedItem };
|
|
366
399
|
}
|
|
367
400
|
} else if (action === "remove" /* Remove */ || hasChanges) {
|
|
368
|
-
pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem });
|
|
401
|
+
pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem, before: omitSyncFields(change.currentItem) });
|
|
369
402
|
}
|
|
370
403
|
}
|
|
371
404
|
}
|
|
@@ -377,6 +410,30 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
|
|
|
377
410
|
if (id) change.id = id;
|
|
378
411
|
}
|
|
379
412
|
}
|
|
413
|
+
function setPendingChangeBefore(get, stateKey, localId, before) {
|
|
414
|
+
const pendingChanges = get().syncState.pendingChanges || [];
|
|
415
|
+
const change = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
|
|
416
|
+
if (change) {
|
|
417
|
+
change.before = { ...change.before, ...before };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function tryUpdateConflicts(pendingChanges, conflicts) {
|
|
421
|
+
if (!conflicts) return conflicts;
|
|
422
|
+
const newConflicts = { ...conflicts };
|
|
423
|
+
for (const change of pendingChanges) {
|
|
424
|
+
const conflict = newConflicts[change.localId];
|
|
425
|
+
if (conflict && change.changes) {
|
|
426
|
+
const newFields = conflict.fields.map((f) => {
|
|
427
|
+
if (f.key in change.changes) {
|
|
428
|
+
return { ...f, localValue: change.changes[f.key] };
|
|
429
|
+
}
|
|
430
|
+
return f;
|
|
431
|
+
});
|
|
432
|
+
newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return newConflicts;
|
|
436
|
+
}
|
|
380
437
|
function findApi(stateKey, syncApi) {
|
|
381
438
|
const api = syncApi[stateKey];
|
|
382
439
|
if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
|
|
@@ -418,6 +475,50 @@ function findChanges(current, updated) {
|
|
|
418
475
|
}
|
|
419
476
|
return changesMap;
|
|
420
477
|
}
|
|
478
|
+
function hasKeysOrUndefined(obj) {
|
|
479
|
+
return Object.keys(obj).length === 0 ? void 0 : obj;
|
|
480
|
+
}
|
|
481
|
+
function hasConflicts(get, localId) {
|
|
482
|
+
const state = get();
|
|
483
|
+
if (state.syncState.conflicts) {
|
|
484
|
+
return !!state.syncState.conflicts[localId];
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
function resolveConflict(set, localId, keepLocalFields) {
|
|
489
|
+
set((state) => {
|
|
490
|
+
const syncState = state.syncState || {};
|
|
491
|
+
const conflicts = syncState.conflicts || {};
|
|
492
|
+
const conflict = conflicts[localId];
|
|
493
|
+
if (conflict) {
|
|
494
|
+
const items = state[conflict.stateKey];
|
|
495
|
+
const item = items.find((i) => i._localId === localId);
|
|
496
|
+
if (!item) {
|
|
497
|
+
return state;
|
|
498
|
+
}
|
|
499
|
+
const resolved = { ...item };
|
|
500
|
+
let pendingChanges = [...syncState.pendingChanges];
|
|
501
|
+
if (!keepLocalFields) {
|
|
502
|
+
for (const field of conflict.fields) {
|
|
503
|
+
resolved[field.key] = field.remoteValue;
|
|
504
|
+
}
|
|
505
|
+
pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
|
|
506
|
+
}
|
|
507
|
+
const nextItems = items.map((i) => i._localId === localId ? resolved : i);
|
|
508
|
+
const nextConflicts = { ...conflicts };
|
|
509
|
+
delete nextConflicts[localId];
|
|
510
|
+
return {
|
|
511
|
+
[conflict.stateKey]: nextItems,
|
|
512
|
+
syncState: {
|
|
513
|
+
...syncState,
|
|
514
|
+
pendingChanges,
|
|
515
|
+
conflicts: hasKeysOrUndefined(nextConflicts)
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return state;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
421
522
|
|
|
422
523
|
// src/pull.ts
|
|
423
524
|
async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
|
|
@@ -428,7 +529,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
428
529
|
if (!serverData?.length) return;
|
|
429
530
|
let newest = lastPulledAt;
|
|
430
531
|
set((state) => {
|
|
431
|
-
|
|
532
|
+
let pendingChanges = [...state.syncState.pendingChanges];
|
|
533
|
+
const conflicts = { ...state.syncState.conflicts };
|
|
432
534
|
const localItems = state[stateKey] || [];
|
|
433
535
|
let nextItems = [...localItems];
|
|
434
536
|
const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
|
|
@@ -452,43 +554,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
452
554
|
if (localItem) {
|
|
453
555
|
const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
|
|
454
556
|
if (pendingChange) {
|
|
557
|
+
logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
|
|
455
558
|
switch (conflictResolutionStrategy) {
|
|
456
|
-
case "
|
|
457
|
-
logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
|
|
559
|
+
case "client-wins":
|
|
458
560
|
break;
|
|
459
|
-
case "
|
|
460
|
-
const merged = {
|
|
461
|
-
...remote,
|
|
462
|
-
_localId: localItem._localId
|
|
463
|
-
};
|
|
561
|
+
case "server-wins": {
|
|
562
|
+
const merged = { ...remote, _localId: localItem._localId };
|
|
464
563
|
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
465
|
-
|
|
564
|
+
pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
|
|
466
565
|
break;
|
|
467
566
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
567
|
+
case "try-shallow-merge": {
|
|
568
|
+
const changes = pendingChange.changes || {};
|
|
569
|
+
const before = pendingChange.before || {};
|
|
570
|
+
const fields = Object.entries(changes).filter(([k, localValue]) => k in before && k in remote && before[k] !== remote[k] && localValue !== remote[k]).map(([key, localValue]) => ({ key, localValue, remoteValue: remote[key] }));
|
|
571
|
+
if (fields.length > 0) {
|
|
572
|
+
logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
|
|
573
|
+
conflicts[localItem._localId] = { stateKey, fields };
|
|
574
|
+
} else {
|
|
575
|
+
const localChangedKeys = Object.keys(changes);
|
|
576
|
+
const preservedLocal = { _localId: localItem._localId };
|
|
577
|
+
for (const k of localChangedKeys) {
|
|
578
|
+
if (k in localItem) preservedLocal[k] = localItem[k];
|
|
579
|
+
}
|
|
580
|
+
const merged = { ...remote, ...preservedLocal };
|
|
581
|
+
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
582
|
+
delete conflicts[localItem._localId];
|
|
583
|
+
}
|
|
480
584
|
break;
|
|
585
|
+
}
|
|
481
586
|
}
|
|
482
587
|
} else {
|
|
483
|
-
const merged = {
|
|
484
|
-
...localItem,
|
|
485
|
-
...remote
|
|
486
|
-
};
|
|
588
|
+
const merged = { ...localItem, ...remote };
|
|
487
589
|
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
488
590
|
logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
|
|
489
591
|
}
|
|
490
592
|
} else {
|
|
491
|
-
nextItems = [...nextItems, { ...remote, _localId:
|
|
593
|
+
nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
|
|
492
594
|
logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
|
|
493
595
|
}
|
|
494
596
|
}
|
|
@@ -496,6 +598,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
496
598
|
[stateKey]: nextItems,
|
|
497
599
|
syncState: {
|
|
498
600
|
...state.syncState || {},
|
|
601
|
+
pendingChanges,
|
|
602
|
+
conflicts: hasKeysOrUndefined(conflicts),
|
|
499
603
|
lastPulled: {
|
|
500
604
|
...state.syncState.lastPulled || {},
|
|
501
605
|
[stateKey]: newest.toISOString()
|
|
@@ -509,7 +613,6 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
509
613
|
async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
|
|
510
614
|
logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
|
|
511
615
|
const { action, stateKey, localId, id, version, changes } = change;
|
|
512
|
-
const changesClone = { ...changes };
|
|
513
616
|
switch (action) {
|
|
514
617
|
case "remove" /* Remove */:
|
|
515
618
|
if (!id) {
|
|
@@ -522,11 +625,17 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
|
|
|
522
625
|
removeFromPendingChanges(set, localId, stateKey);
|
|
523
626
|
break;
|
|
524
627
|
case "update" /* Update */: {
|
|
525
|
-
|
|
628
|
+
if (hasConflicts(get, change.localId)) {
|
|
629
|
+
logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const exists = await api.update(id, changes);
|
|
526
633
|
if (exists) {
|
|
527
634
|
logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
528
635
|
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
529
636
|
removeFromPendingChanges(set, localId, stateKey);
|
|
637
|
+
} else {
|
|
638
|
+
setPendingChangeBefore(get, stateKey, localId, changes);
|
|
530
639
|
}
|
|
531
640
|
return;
|
|
532
641
|
} else {
|
|
@@ -548,7 +657,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
|
|
|
548
657
|
case "insert-remote-record": {
|
|
549
658
|
const newItem = {
|
|
550
659
|
...item,
|
|
551
|
-
_localId:
|
|
660
|
+
_localId: createLocalId(),
|
|
552
661
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
553
662
|
};
|
|
554
663
|
setAndQueueToSync((s) => ({
|
|
@@ -570,7 +679,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
|
|
|
570
679
|
break;
|
|
571
680
|
}
|
|
572
681
|
case "create" /* Create */: {
|
|
573
|
-
const result = await api.add(
|
|
682
|
+
const result = await api.add(changes);
|
|
574
683
|
if (result) {
|
|
575
684
|
logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
576
685
|
set((s) => ({
|
|
@@ -627,7 +736,7 @@ async function startFirstLoad(set, syncApi, logger) {
|
|
|
627
736
|
} else {
|
|
628
737
|
next.push({
|
|
629
738
|
...remote,
|
|
630
|
-
_localId:
|
|
739
|
+
_localId: createLocalId()
|
|
631
740
|
});
|
|
632
741
|
}
|
|
633
742
|
}
|
|
@@ -735,17 +844,17 @@ function createIndexedDBStorage(options) {
|
|
|
735
844
|
}
|
|
736
845
|
|
|
737
846
|
// src/index.ts
|
|
738
|
-
var SyncAction = /* @__PURE__ */ ((
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
return
|
|
847
|
+
var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
|
|
848
|
+
SyncAction3["Create"] = "create";
|
|
849
|
+
SyncAction3["Update"] = "update";
|
|
850
|
+
SyncAction3["Remove"] = "remove";
|
|
851
|
+
return SyncAction3;
|
|
743
852
|
})(SyncAction || {});
|
|
744
|
-
var DEFAULT_SYNC_INTERVAL_MILLIS =
|
|
853
|
+
var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
|
|
745
854
|
var DEFAULT_LOGGER = console;
|
|
746
855
|
var DEFAULT_MIN_LOG_LEVEL = "debug";
|
|
747
856
|
var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
|
|
748
|
-
var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "
|
|
857
|
+
var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
|
|
749
858
|
function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
750
859
|
const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
|
|
751
860
|
return new Promise((resolve) => {
|
|
@@ -782,7 +891,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
782
891
|
syncState: {
|
|
783
892
|
firstLoadDone: syncState.firstLoadDone,
|
|
784
893
|
pendingChanges: syncState.pendingChanges,
|
|
785
|
-
lastPulled: syncState.lastPulled
|
|
894
|
+
lastPulled: syncState.lastPulled,
|
|
895
|
+
conflicts: syncState.conflicts
|
|
786
896
|
}
|
|
787
897
|
};
|
|
788
898
|
},
|
|
@@ -791,7 +901,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
791
901
|
return {
|
|
792
902
|
...state,
|
|
793
903
|
syncState: {
|
|
794
|
-
...state.syncState,
|
|
904
|
+
...state.syncState || {},
|
|
795
905
|
status: "idle"
|
|
796
906
|
// this confirms 'hydrating' is done
|
|
797
907
|
}
|
|
@@ -799,13 +909,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
799
909
|
}
|
|
800
910
|
};
|
|
801
911
|
const creator = (set, get, storeApi) => {
|
|
802
|
-
let
|
|
912
|
+
let syncTimerStarted = false;
|
|
803
913
|
async function syncOnce() {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
set((state2) => ({
|
|
914
|
+
if (get().syncState.status !== "idle") return;
|
|
915
|
+
set((state) => ({
|
|
807
916
|
syncState: {
|
|
808
|
-
...
|
|
917
|
+
...state.syncState || {},
|
|
809
918
|
status: "syncing"
|
|
810
919
|
}
|
|
811
920
|
}));
|
|
@@ -840,24 +949,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
840
949
|
logger.error(`[zync] push:error change=${change}`, err);
|
|
841
950
|
}
|
|
842
951
|
}
|
|
843
|
-
set((
|
|
952
|
+
set((state) => ({
|
|
844
953
|
syncState: {
|
|
845
|
-
...
|
|
954
|
+
...state.syncState || {},
|
|
846
955
|
status: "idle",
|
|
847
956
|
error: firstSyncError
|
|
848
957
|
}
|
|
849
958
|
}));
|
|
850
|
-
if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
|
|
851
|
-
await syncOnce();
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
function setAndSyncOnce(partial) {
|
|
855
|
-
if (typeof partial === "function") {
|
|
856
|
-
set((state) => ({ ...partial(state) }));
|
|
857
|
-
} else {
|
|
858
|
-
set(partial);
|
|
859
|
-
}
|
|
860
|
-
syncOnce();
|
|
861
959
|
}
|
|
862
960
|
function setAndQueueToSync(partial) {
|
|
863
961
|
if (typeof partial === "function") {
|
|
@@ -865,7 +963,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
865
963
|
} else {
|
|
866
964
|
set((state) => newSyncState(state, partial));
|
|
867
965
|
}
|
|
868
|
-
syncOnce();
|
|
869
966
|
}
|
|
870
967
|
function newSyncState(state, partial) {
|
|
871
968
|
const pendingChanges = state.syncState.pendingChanges || [];
|
|
@@ -875,11 +972,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
875
972
|
const changes = findChanges(current, updated);
|
|
876
973
|
tryAddToPendingChanges(pendingChanges, stateKey, changes);
|
|
877
974
|
});
|
|
975
|
+
const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
|
|
878
976
|
return {
|
|
879
977
|
...partial,
|
|
880
978
|
syncState: {
|
|
881
979
|
...state.syncState || {},
|
|
882
|
-
pendingChanges
|
|
980
|
+
pendingChanges,
|
|
981
|
+
conflicts
|
|
883
982
|
}
|
|
884
983
|
};
|
|
885
984
|
}
|
|
@@ -887,18 +986,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
887
986
|
set((state) => ({
|
|
888
987
|
syncState: {
|
|
889
988
|
...state.syncState || {},
|
|
890
|
-
enabled
|
|
989
|
+
status: enabled ? "idle" : "disabled"
|
|
891
990
|
}
|
|
892
991
|
}));
|
|
893
|
-
|
|
992
|
+
startSyncTimer(enabled);
|
|
894
993
|
addVisibilityChangeListener(enabled);
|
|
895
994
|
}
|
|
896
|
-
function
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
995
|
+
function startSyncTimer(start) {
|
|
996
|
+
if (start) {
|
|
997
|
+
tryStart();
|
|
998
|
+
} else {
|
|
999
|
+
syncTimerStarted = false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
async function tryStart() {
|
|
1003
|
+
if (syncTimerStarted) return;
|
|
1004
|
+
syncTimerStarted = true;
|
|
1005
|
+
while (true) {
|
|
1006
|
+
if (!syncTimerStarted) break;
|
|
1007
|
+
await syncOnce();
|
|
1008
|
+
await sleep(syncInterval);
|
|
902
1009
|
}
|
|
903
1010
|
}
|
|
904
1011
|
function addVisibilityChangeListener(add) {
|
|
@@ -911,24 +1018,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
911
1018
|
function onVisibilityChange() {
|
|
912
1019
|
if (document.visibilityState === "visible") {
|
|
913
1020
|
logger.debug("[zync] sync:start-in-foreground");
|
|
914
|
-
|
|
1021
|
+
startSyncTimer(true);
|
|
915
1022
|
} else {
|
|
916
1023
|
logger.debug("[zync] sync:pause-in-background");
|
|
917
|
-
|
|
1024
|
+
startSyncTimer(false);
|
|
918
1025
|
}
|
|
919
1026
|
}
|
|
920
1027
|
storeApi.sync = {
|
|
921
1028
|
enable,
|
|
922
|
-
startFirstLoad: () => startFirstLoad(set, syncApi, logger)
|
|
1029
|
+
startFirstLoad: () => startFirstLoad(set, syncApi, logger),
|
|
1030
|
+
resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
|
|
923
1031
|
};
|
|
924
|
-
const userState = stateCreator(
|
|
1032
|
+
const userState = stateCreator(set, get, setAndQueueToSync);
|
|
925
1033
|
return {
|
|
926
1034
|
...userState,
|
|
927
1035
|
syncState: {
|
|
928
1036
|
// set defaults
|
|
929
1037
|
status: "hydrating",
|
|
930
1038
|
error: void 0,
|
|
931
|
-
|
|
1039
|
+
conflicts: void 0,
|
|
932
1040
|
firstLoadDone: false,
|
|
933
1041
|
pendingChanges: [],
|
|
934
1042
|
lastPulled: {}
|
|
@@ -940,9 +1048,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
940
1048
|
// Annotate the CommonJS export names for ESM import in node:
|
|
941
1049
|
0 && (module.exports = {
|
|
942
1050
|
SyncAction,
|
|
1051
|
+
changeKeysFrom,
|
|
1052
|
+
changeKeysTo,
|
|
943
1053
|
createIndexedDBStorage,
|
|
1054
|
+
createLocalId,
|
|
944
1055
|
createWithSync,
|
|
945
|
-
nextLocalId,
|
|
946
1056
|
persistWithSync
|
|
947
1057
|
});
|
|
948
1058
|
//# sourceMappingURL=index.cjs.map
|