@anfenn/zync 0.3.3 → 0.4.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 +27 -22
- package/dist/index.cjs +178 -76
- 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 +175 -75
- 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 };
|
|
@@ -204,8 +209,8 @@ async function add(item: any): Promise<any | undefined> {
|
|
|
204
209
|
throw new Error(error.message);
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
if (data
|
|
208
|
-
//
|
|
212
|
+
if (data?.length > 0) {
|
|
213
|
+
// Return server id if not using client assigned id's, and any other fields you want merged in
|
|
209
214
|
return { id: data[0].id };
|
|
210
215
|
}
|
|
211
216
|
}
|
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, current: omitSyncFields(change.currentItem) });
|
|
369
402
|
}
|
|
370
403
|
}
|
|
371
404
|
}
|
|
@@ -377,6 +410,23 @@ function setPendingChangeToUpdate(get, stateKey, localId, id) {
|
|
|
377
410
|
if (id) change.id = id;
|
|
378
411
|
}
|
|
379
412
|
}
|
|
413
|
+
function tryUpdateConflicts(pendingChanges, conflicts) {
|
|
414
|
+
if (!conflicts) return conflicts;
|
|
415
|
+
const newConflicts = { ...conflicts };
|
|
416
|
+
for (const change of pendingChanges) {
|
|
417
|
+
const conflict = newConflicts[change.localId];
|
|
418
|
+
if (conflict && change.changes) {
|
|
419
|
+
const newFields = conflict.fields.map((f) => {
|
|
420
|
+
if (f.key in change.changes) {
|
|
421
|
+
return { ...f, localValue: change.changes[f.key] };
|
|
422
|
+
}
|
|
423
|
+
return f;
|
|
424
|
+
});
|
|
425
|
+
newConflicts[change.localId] = { stateKey: conflict.stateKey, fields: newFields };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return newConflicts;
|
|
429
|
+
}
|
|
380
430
|
function findApi(stateKey, syncApi) {
|
|
381
431
|
const api = syncApi[stateKey];
|
|
382
432
|
if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
|
|
@@ -418,6 +468,50 @@ function findChanges(current, updated) {
|
|
|
418
468
|
}
|
|
419
469
|
return changesMap;
|
|
420
470
|
}
|
|
471
|
+
function hasKeysOrUndefined(obj) {
|
|
472
|
+
return Object.keys(obj).length === 0 ? void 0 : obj;
|
|
473
|
+
}
|
|
474
|
+
function hasConflicts(get, localId) {
|
|
475
|
+
const state = get();
|
|
476
|
+
if (state.syncState.conflicts) {
|
|
477
|
+
return !!state.syncState.conflicts[localId];
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
function resolveConflict(set, localId, keepLocalFields) {
|
|
482
|
+
set((state) => {
|
|
483
|
+
const syncState = state.syncState || {};
|
|
484
|
+
const conflicts = syncState.conflicts || {};
|
|
485
|
+
const conflict = conflicts[localId];
|
|
486
|
+
if (conflict) {
|
|
487
|
+
const items = state[conflict.stateKey];
|
|
488
|
+
const item = items.find((i) => i._localId === localId);
|
|
489
|
+
if (!item) {
|
|
490
|
+
return state;
|
|
491
|
+
}
|
|
492
|
+
const resolved = { ...item };
|
|
493
|
+
let pendingChanges = [...syncState.pendingChanges];
|
|
494
|
+
if (!keepLocalFields) {
|
|
495
|
+
for (const field of conflict.fields) {
|
|
496
|
+
resolved[field.key] = field.remoteValue;
|
|
497
|
+
}
|
|
498
|
+
pendingChanges = pendingChanges.filter((p) => !(p.stateKey === conflict.stateKey && p.localId === localId));
|
|
499
|
+
}
|
|
500
|
+
const nextItems = items.map((i) => i._localId === localId ? resolved : i);
|
|
501
|
+
const nextConflicts = { ...conflicts };
|
|
502
|
+
delete nextConflicts[localId];
|
|
503
|
+
return {
|
|
504
|
+
[conflict.stateKey]: nextItems,
|
|
505
|
+
syncState: {
|
|
506
|
+
...syncState,
|
|
507
|
+
pendingChanges,
|
|
508
|
+
conflicts: hasKeysOrUndefined(nextConflicts)
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
return state;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
421
515
|
|
|
422
516
|
// src/pull.ts
|
|
423
517
|
async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy) {
|
|
@@ -428,7 +522,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
428
522
|
if (!serverData?.length) return;
|
|
429
523
|
let newest = lastPulledAt;
|
|
430
524
|
set((state) => {
|
|
431
|
-
|
|
525
|
+
let pendingChanges = [...state.syncState.pendingChanges];
|
|
526
|
+
const conflicts = { ...state.syncState.conflicts };
|
|
432
527
|
const localItems = state[stateKey] || [];
|
|
433
528
|
let nextItems = [...localItems];
|
|
434
529
|
const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
|
|
@@ -452,43 +547,43 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
452
547
|
if (localItem) {
|
|
453
548
|
const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
|
|
454
549
|
if (pendingChange) {
|
|
550
|
+
logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
|
|
455
551
|
switch (conflictResolutionStrategy) {
|
|
456
|
-
case "
|
|
457
|
-
logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
|
|
552
|
+
case "client-wins":
|
|
458
553
|
break;
|
|
459
|
-
case "
|
|
460
|
-
const merged = {
|
|
461
|
-
...remote,
|
|
462
|
-
_localId: localItem._localId
|
|
463
|
-
};
|
|
554
|
+
case "server-wins": {
|
|
555
|
+
const merged = { ...remote, _localId: localItem._localId };
|
|
464
556
|
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
465
|
-
|
|
557
|
+
pendingChanges = pendingChanges.filter((p) => !(p.stateKey === stateKey && p.localId === localItem._localId));
|
|
466
558
|
break;
|
|
467
559
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
560
|
+
case "try-shallow-merge": {
|
|
561
|
+
const changes = pendingChange.changes || {};
|
|
562
|
+
const current = pendingChange.current || {};
|
|
563
|
+
const fields = Object.entries(changes).filter(([k, localValue]) => k in current && k in remote && current[k] !== remote[k] && localValue !== remote[k]).map(([key, localValue]) => ({ key, localValue, remoteValue: remote[key] }));
|
|
564
|
+
if (fields.length > 0) {
|
|
565
|
+
logger.warn(`[zync] pull:${conflictResolutionStrategy}:conflicts-found`, JSON.stringify(fields, null, 4));
|
|
566
|
+
conflicts[localItem._localId] = { stateKey, fields };
|
|
567
|
+
} else {
|
|
568
|
+
const localChangedKeys = Object.keys(changes);
|
|
569
|
+
const preservedLocal = { _localId: localItem._localId };
|
|
570
|
+
for (const k of localChangedKeys) {
|
|
571
|
+
if (k in localItem) preservedLocal[k] = localItem[k];
|
|
572
|
+
}
|
|
573
|
+
const merged = { ...remote, ...preservedLocal };
|
|
574
|
+
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
575
|
+
delete conflicts[localItem._localId];
|
|
576
|
+
}
|
|
480
577
|
break;
|
|
578
|
+
}
|
|
481
579
|
}
|
|
482
580
|
} else {
|
|
483
|
-
const merged = {
|
|
484
|
-
...localItem,
|
|
485
|
-
...remote
|
|
486
|
-
};
|
|
581
|
+
const merged = { ...localItem, ...remote };
|
|
487
582
|
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
488
583
|
logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
|
|
489
584
|
}
|
|
490
585
|
} else {
|
|
491
|
-
nextItems = [...nextItems, { ...remote, _localId:
|
|
586
|
+
nextItems = [...nextItems, { ...remote, _localId: createLocalId() }];
|
|
492
587
|
logger.debug(`[zync] pull:add stateKey=${stateKey} id=${remote.id}`);
|
|
493
588
|
}
|
|
494
589
|
}
|
|
@@ -496,6 +591,8 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
|
|
|
496
591
|
[stateKey]: nextItems,
|
|
497
592
|
syncState: {
|
|
498
593
|
...state.syncState || {},
|
|
594
|
+
pendingChanges,
|
|
595
|
+
conflicts: hasKeysOrUndefined(conflicts),
|
|
499
596
|
lastPulled: {
|
|
500
597
|
...state.syncState.lastPulled || {},
|
|
501
598
|
[stateKey]: newest.toISOString()
|
|
@@ -522,6 +619,10 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
|
|
|
522
619
|
removeFromPendingChanges(set, localId, stateKey);
|
|
523
620
|
break;
|
|
524
621
|
case "update" /* Update */: {
|
|
622
|
+
if (hasConflicts(get, change.localId)) {
|
|
623
|
+
logger.warn(`[zync] push:update:skipping-with-conflicts stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
525
626
|
const exists = await api.update(id, changesClone);
|
|
526
627
|
if (exists) {
|
|
527
628
|
logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
|
|
@@ -548,7 +649,7 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
|
|
|
548
649
|
case "insert-remote-record": {
|
|
549
650
|
const newItem = {
|
|
550
651
|
...item,
|
|
551
|
-
_localId:
|
|
652
|
+
_localId: createLocalId(),
|
|
552
653
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
553
654
|
};
|
|
554
655
|
setAndQueueToSync((s) => ({
|
|
@@ -627,7 +728,7 @@ async function startFirstLoad(set, syncApi, logger) {
|
|
|
627
728
|
} else {
|
|
628
729
|
next.push({
|
|
629
730
|
...remote,
|
|
630
|
-
_localId:
|
|
731
|
+
_localId: createLocalId()
|
|
631
732
|
});
|
|
632
733
|
}
|
|
633
734
|
}
|
|
@@ -735,17 +836,17 @@ function createIndexedDBStorage(options) {
|
|
|
735
836
|
}
|
|
736
837
|
|
|
737
838
|
// src/index.ts
|
|
738
|
-
var SyncAction = /* @__PURE__ */ ((
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
return
|
|
839
|
+
var SyncAction = /* @__PURE__ */ ((SyncAction3) => {
|
|
840
|
+
SyncAction3["Create"] = "create";
|
|
841
|
+
SyncAction3["Update"] = "update";
|
|
842
|
+
SyncAction3["Remove"] = "remove";
|
|
843
|
+
return SyncAction3;
|
|
743
844
|
})(SyncAction || {});
|
|
744
|
-
var DEFAULT_SYNC_INTERVAL_MILLIS =
|
|
845
|
+
var DEFAULT_SYNC_INTERVAL_MILLIS = 2e3;
|
|
745
846
|
var DEFAULT_LOGGER = console;
|
|
746
847
|
var DEFAULT_MIN_LOG_LEVEL = "debug";
|
|
747
848
|
var DEFAULT_MISSING_REMOTE_RECORD_STRATEGY = "ignore";
|
|
748
|
-
var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "
|
|
849
|
+
var DEFAULT_CONFLICT_RESOLUTION_STRATEGY = "client-wins";
|
|
749
850
|
function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
750
851
|
const store = (0, import_zustand.create)(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
|
|
751
852
|
return new Promise((resolve) => {
|
|
@@ -782,7 +883,8 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
782
883
|
syncState: {
|
|
783
884
|
firstLoadDone: syncState.firstLoadDone,
|
|
784
885
|
pendingChanges: syncState.pendingChanges,
|
|
785
|
-
lastPulled: syncState.lastPulled
|
|
886
|
+
lastPulled: syncState.lastPulled,
|
|
887
|
+
conflicts: syncState.conflicts
|
|
786
888
|
}
|
|
787
889
|
};
|
|
788
890
|
},
|
|
@@ -791,7 +893,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
791
893
|
return {
|
|
792
894
|
...state,
|
|
793
895
|
syncState: {
|
|
794
|
-
...state.syncState,
|
|
896
|
+
...state.syncState || {},
|
|
795
897
|
status: "idle"
|
|
796
898
|
// this confirms 'hydrating' is done
|
|
797
899
|
}
|
|
@@ -799,13 +901,12 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
799
901
|
}
|
|
800
902
|
};
|
|
801
903
|
const creator = (set, get, storeApi) => {
|
|
802
|
-
let
|
|
904
|
+
let syncTimerStarted = false;
|
|
803
905
|
async function syncOnce() {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
set((state2) => ({
|
|
906
|
+
if (get().syncState.status !== "idle") return;
|
|
907
|
+
set((state) => ({
|
|
807
908
|
syncState: {
|
|
808
|
-
...
|
|
909
|
+
...state.syncState || {},
|
|
809
910
|
status: "syncing"
|
|
810
911
|
}
|
|
811
912
|
}));
|
|
@@ -840,24 +941,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
840
941
|
logger.error(`[zync] push:error change=${change}`, err);
|
|
841
942
|
}
|
|
842
943
|
}
|
|
843
|
-
set((
|
|
944
|
+
set((state) => ({
|
|
844
945
|
syncState: {
|
|
845
|
-
...
|
|
946
|
+
...state.syncState || {},
|
|
846
947
|
status: "idle",
|
|
847
948
|
error: firstSyncError
|
|
848
949
|
}
|
|
849
950
|
}));
|
|
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
951
|
}
|
|
862
952
|
function setAndQueueToSync(partial) {
|
|
863
953
|
if (typeof partial === "function") {
|
|
@@ -865,7 +955,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
865
955
|
} else {
|
|
866
956
|
set((state) => newSyncState(state, partial));
|
|
867
957
|
}
|
|
868
|
-
syncOnce();
|
|
869
958
|
}
|
|
870
959
|
function newSyncState(state, partial) {
|
|
871
960
|
const pendingChanges = state.syncState.pendingChanges || [];
|
|
@@ -875,11 +964,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
875
964
|
const changes = findChanges(current, updated);
|
|
876
965
|
tryAddToPendingChanges(pendingChanges, stateKey, changes);
|
|
877
966
|
});
|
|
967
|
+
const conflicts = tryUpdateConflicts(pendingChanges, state.syncState.conflicts);
|
|
878
968
|
return {
|
|
879
969
|
...partial,
|
|
880
970
|
syncState: {
|
|
881
971
|
...state.syncState || {},
|
|
882
|
-
pendingChanges
|
|
972
|
+
pendingChanges,
|
|
973
|
+
conflicts
|
|
883
974
|
}
|
|
884
975
|
};
|
|
885
976
|
}
|
|
@@ -887,18 +978,26 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
887
978
|
set((state) => ({
|
|
888
979
|
syncState: {
|
|
889
980
|
...state.syncState || {},
|
|
890
|
-
enabled
|
|
981
|
+
status: enabled ? "idle" : "disabled"
|
|
891
982
|
}
|
|
892
983
|
}));
|
|
893
|
-
|
|
984
|
+
startSyncTimer(enabled);
|
|
894
985
|
addVisibilityChangeListener(enabled);
|
|
895
986
|
}
|
|
896
|
-
function
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
987
|
+
function startSyncTimer(start) {
|
|
988
|
+
if (start) {
|
|
989
|
+
tryStart();
|
|
990
|
+
} else {
|
|
991
|
+
syncTimerStarted = false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
async function tryStart() {
|
|
995
|
+
if (syncTimerStarted) return;
|
|
996
|
+
syncTimerStarted = true;
|
|
997
|
+
while (true) {
|
|
998
|
+
if (!syncTimerStarted) break;
|
|
999
|
+
await syncOnce();
|
|
1000
|
+
await sleep(syncInterval);
|
|
902
1001
|
}
|
|
903
1002
|
}
|
|
904
1003
|
function addVisibilityChangeListener(add) {
|
|
@@ -911,24 +1010,25 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
911
1010
|
function onVisibilityChange() {
|
|
912
1011
|
if (document.visibilityState === "visible") {
|
|
913
1012
|
logger.debug("[zync] sync:start-in-foreground");
|
|
914
|
-
|
|
1013
|
+
startSyncTimer(true);
|
|
915
1014
|
} else {
|
|
916
1015
|
logger.debug("[zync] sync:pause-in-background");
|
|
917
|
-
|
|
1016
|
+
startSyncTimer(false);
|
|
918
1017
|
}
|
|
919
1018
|
}
|
|
920
1019
|
storeApi.sync = {
|
|
921
1020
|
enable,
|
|
922
|
-
startFirstLoad: () => startFirstLoad(set, syncApi, logger)
|
|
1021
|
+
startFirstLoad: () => startFirstLoad(set, syncApi, logger),
|
|
1022
|
+
resolveConflict: (localId, keepLocal) => resolveConflict(set, localId, keepLocal)
|
|
923
1023
|
};
|
|
924
|
-
const userState = stateCreator(
|
|
1024
|
+
const userState = stateCreator(set, get, setAndQueueToSync);
|
|
925
1025
|
return {
|
|
926
1026
|
...userState,
|
|
927
1027
|
syncState: {
|
|
928
1028
|
// set defaults
|
|
929
1029
|
status: "hydrating",
|
|
930
1030
|
error: void 0,
|
|
931
|
-
|
|
1031
|
+
conflicts: void 0,
|
|
932
1032
|
firstLoadDone: false,
|
|
933
1033
|
pendingChanges: [],
|
|
934
1034
|
lastPulled: {}
|
|
@@ -940,9 +1040,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
940
1040
|
// Annotate the CommonJS export names for ESM import in node:
|
|
941
1041
|
0 && (module.exports = {
|
|
942
1042
|
SyncAction,
|
|
1043
|
+
changeKeysFrom,
|
|
1044
|
+
changeKeysTo,
|
|
943
1045
|
createIndexedDBStorage,
|
|
1046
|
+
createLocalId,
|
|
944
1047
|
createWithSync,
|
|
945
|
-
nextLocalId,
|
|
946
1048
|
persistWithSync
|
|
947
1049
|
});
|
|
948
1050
|
//# sourceMappingURL=index.cjs.map
|