@anfenn/zync 0.1.9 → 0.1.10
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 +21 -0
- package/dist/build-ELJGW24D.js +252 -0
- package/dist/build-ELJGW24D.js.map +1 -0
- package/dist/index.cjs +589 -317
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -38
- package/dist/index.d.ts +35 -38
- package/dist/index.js +326 -308
- package/dist/index.js.map +1 -1
- package/package.json +12 -9
package/dist/index.js
CHANGED
|
@@ -2,27 +2,253 @@
|
|
|
2
2
|
import { create } from "zustand";
|
|
3
3
|
import { persist } from "zustand/middleware";
|
|
4
4
|
|
|
5
|
+
// src/logger.ts
|
|
6
|
+
function newLogger(base, min) {
|
|
7
|
+
const order = {
|
|
8
|
+
debug: 10,
|
|
9
|
+
info: 20,
|
|
10
|
+
warn: 30,
|
|
11
|
+
error: 40,
|
|
12
|
+
none: 100
|
|
13
|
+
};
|
|
14
|
+
const threshold = order[min];
|
|
15
|
+
const enabled = (lvl) => order[lvl] >= threshold;
|
|
16
|
+
return {
|
|
17
|
+
debug: (...a) => enabled("debug") && base.debug?.(...a),
|
|
18
|
+
info: (...a) => enabled("info") && base.info?.(...a),
|
|
19
|
+
warn: (...a) => enabled("warn") && base.warn?.(...a),
|
|
20
|
+
error: (...a) => enabled("error") && base.error?.(...a)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/helpers.ts
|
|
25
|
+
function nextLocalId() {
|
|
26
|
+
return crypto.randomUUID();
|
|
27
|
+
}
|
|
28
|
+
function orderFor(a) {
|
|
29
|
+
switch (a) {
|
|
30
|
+
case "createOrUpdate" /* CreateOrUpdate */:
|
|
31
|
+
return 1;
|
|
32
|
+
case "remove" /* Remove */:
|
|
33
|
+
return 2;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function omitSyncFields(item, fields) {
|
|
37
|
+
const result = { ...item };
|
|
38
|
+
for (const k of fields) delete result[k];
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
function samePendingVersion(get, stateKey, localId, version) {
|
|
42
|
+
const q = get().syncState.pendingChanges || [];
|
|
43
|
+
const curChange = q.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
44
|
+
return curChange?.version === version;
|
|
45
|
+
}
|
|
46
|
+
function removeFromPendingChanges(set, localId, stateKey) {
|
|
47
|
+
set((s) => {
|
|
48
|
+
const queue = (s.syncState.pendingChanges || []).filter((p) => !(p.localId === localId && p.stateKey === stateKey));
|
|
49
|
+
return {
|
|
50
|
+
syncState: {
|
|
51
|
+
...s.syncState || {},
|
|
52
|
+
pendingChanges: queue
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function findApi(stateKey, syncApi) {
|
|
58
|
+
const api = syncApi[stateKey];
|
|
59
|
+
if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
|
|
60
|
+
throw new Error(`Missing API function(s) for state key: ${stateKey}.`);
|
|
61
|
+
}
|
|
62
|
+
return api;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/pull.ts
|
|
66
|
+
async function pull(set, get, stateKey, api, logger) {
|
|
67
|
+
const lastPulled = get().syncState.lastPulled || {};
|
|
68
|
+
const lastPulledAt = new Date(lastPulled[stateKey] || /* @__PURE__ */ new Date(0));
|
|
69
|
+
logger.debug(`[Zync] pull:start stateKey=${stateKey} since=${lastPulledAt.toISOString()}`);
|
|
70
|
+
const serverData = await api.list(lastPulledAt);
|
|
71
|
+
if (!serverData?.length) return;
|
|
72
|
+
let newest = lastPulledAt;
|
|
73
|
+
set((state) => {
|
|
74
|
+
const pendingChanges = state.syncState.pendingChanges || [];
|
|
75
|
+
const localItems = state[stateKey] || [];
|
|
76
|
+
let nextItems = [...localItems];
|
|
77
|
+
const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
|
|
78
|
+
const pendingRemovalById = new Set(pendingChanges.filter((p) => p.stateKey === stateKey && p.action === "remove" /* Remove */).map((p) => p.id));
|
|
79
|
+
for (const remote of serverData) {
|
|
80
|
+
const remoteUpdated = new Date(remote.updated_at);
|
|
81
|
+
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
82
|
+
if (pendingRemovalById.has(remote.id)) {
|
|
83
|
+
logger.debug(`[Zync] pull:skip-pending-remove stateKey=${stateKey} id=${remote.id}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const localItem = localById.get(remote.id);
|
|
87
|
+
if (remote.deleted) {
|
|
88
|
+
if (localItem) {
|
|
89
|
+
nextItems = nextItems.filter((i) => i.id !== remote.id);
|
|
90
|
+
logger.debug(`[Zync] pull:remove stateKey=${stateKey} id=${remote.id}`);
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
|
|
95
|
+
if (localItem && !pending) {
|
|
96
|
+
const merged = {
|
|
97
|
+
...localItem,
|
|
98
|
+
...remote,
|
|
99
|
+
_localId: localItem._localId
|
|
100
|
+
};
|
|
101
|
+
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
102
|
+
logger.debug(`[Zync] pull:merge stateKey=${stateKey} id=${remote.id}`);
|
|
103
|
+
} else if (!localItem) {
|
|
104
|
+
nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
|
|
105
|
+
logger.debug(`[Zync] pull:add stateKey=${stateKey} id=${remote.id}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
[stateKey]: nextItems,
|
|
110
|
+
syncState: {
|
|
111
|
+
...state.syncState || {},
|
|
112
|
+
lastPulled: {
|
|
113
|
+
...state.syncState.lastPulled || {},
|
|
114
|
+
[stateKey]: newest.toISOString()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/push.ts
|
|
122
|
+
var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
|
|
123
|
+
async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
|
|
124
|
+
logger.debug(`[Zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
|
|
125
|
+
const { action, stateKey, localId, id, version } = change;
|
|
126
|
+
switch (action) {
|
|
127
|
+
case "remove" /* Remove */:
|
|
128
|
+
await api.remove(id);
|
|
129
|
+
logger.debug(`[Zync] push:remove:success ${stateKey} ${localId} ${id}`);
|
|
130
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
131
|
+
break;
|
|
132
|
+
case "createOrUpdate" /* CreateOrUpdate */: {
|
|
133
|
+
const state = get();
|
|
134
|
+
const items = state[stateKey] || [];
|
|
135
|
+
const item = items.find((i) => i._localId === localId);
|
|
136
|
+
if (!item) {
|
|
137
|
+
logger.warn(`[Zync] push:${action}:no-local-item`, {
|
|
138
|
+
stateKey,
|
|
139
|
+
localId
|
|
140
|
+
});
|
|
141
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const omittedItem = omitSyncFields(item, SYNC_FIELDS);
|
|
145
|
+
if (item.id) {
|
|
146
|
+
const changed = await api.update(item.id, omittedItem);
|
|
147
|
+
if (changed) {
|
|
148
|
+
logger.debug("[Zync] push:update:success", {
|
|
149
|
+
stateKey,
|
|
150
|
+
localId,
|
|
151
|
+
id: item.id
|
|
152
|
+
});
|
|
153
|
+
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
154
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
} else {
|
|
158
|
+
logger.warn("[Zync] push:update:missingRemote", {
|
|
159
|
+
stateKey,
|
|
160
|
+
localId,
|
|
161
|
+
id: item.id
|
|
162
|
+
});
|
|
163
|
+
switch (missingStrategy) {
|
|
164
|
+
case "deleteLocalRecord":
|
|
165
|
+
set((s) => ({
|
|
166
|
+
[stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
|
|
167
|
+
}));
|
|
168
|
+
break;
|
|
169
|
+
case "insertNewRemoteRecord": {
|
|
170
|
+
omittedItem._localId = crypto.randomUUID();
|
|
171
|
+
omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
172
|
+
set((s) => ({
|
|
173
|
+
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
|
|
174
|
+
}));
|
|
175
|
+
queueToSync("createOrUpdate" /* CreateOrUpdate */, stateKey, omittedItem._localId);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
180
|
+
onMissingRemoteRecordDuringUpdate?.(missingStrategy, omittedItem, omittedItem._localId);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const result = await api.add(omittedItem);
|
|
185
|
+
if (result) {
|
|
186
|
+
logger.debug("[Zync] push:create:success", {
|
|
187
|
+
stateKey,
|
|
188
|
+
localId,
|
|
189
|
+
id: result.id
|
|
190
|
+
});
|
|
191
|
+
set((s) => ({
|
|
192
|
+
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
|
|
193
|
+
}));
|
|
194
|
+
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
195
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
196
|
+
}
|
|
197
|
+
onAfterRemoteAdd?.(set, get, queueToSync, stateKey, {
|
|
198
|
+
...item,
|
|
199
|
+
...result
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
logger.warn("[Zync] push:create:no-result", {
|
|
203
|
+
stateKey,
|
|
204
|
+
localId
|
|
205
|
+
});
|
|
206
|
+
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
207
|
+
removeFromPendingChanges(set, localId, stateKey);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
5
215
|
// src/indexedDBStorage.ts
|
|
6
|
-
import { openDB } from "idb";
|
|
7
216
|
function createIndexedDBStorage(options) {
|
|
8
217
|
const dbName = options.dbName;
|
|
9
218
|
const storeName = options.storeName;
|
|
10
|
-
let dbPromise =
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
219
|
+
let dbPromise = null;
|
|
220
|
+
async function initDB() {
|
|
221
|
+
if (dbPromise) return dbPromise;
|
|
222
|
+
try {
|
|
223
|
+
const idb = await import(
|
|
224
|
+
/* webpackChunkName: "idb" */
|
|
225
|
+
"./build-ELJGW24D.js"
|
|
226
|
+
);
|
|
227
|
+
dbPromise = idb.openDB(dbName, 1, {
|
|
228
|
+
upgrade(db) {
|
|
229
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
230
|
+
db.createObjectStore(storeName);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return dbPromise;
|
|
235
|
+
} catch (_e) {
|
|
236
|
+
throw new Error('Missing optional dependency "idb". Install it to use IndexedDB storage: npm install idb');
|
|
15
237
|
}
|
|
16
|
-
}
|
|
238
|
+
}
|
|
17
239
|
async function ensureStore() {
|
|
18
|
-
const db = await
|
|
240
|
+
const db = await initDB();
|
|
19
241
|
if (db.objectStoreNames.contains(storeName)) return;
|
|
20
242
|
const nextVersion = (db.version || 0) + 1;
|
|
21
243
|
try {
|
|
22
244
|
db.close();
|
|
23
|
-
} catch (
|
|
245
|
+
} catch (_e) {
|
|
24
246
|
}
|
|
25
|
-
|
|
247
|
+
const idb = await import(
|
|
248
|
+
/* webpackChunkName: "idb" */
|
|
249
|
+
"./build-ELJGW24D.js"
|
|
250
|
+
);
|
|
251
|
+
dbPromise = idb.openDB(dbName, nextVersion, {
|
|
26
252
|
upgrade(upg) {
|
|
27
253
|
if (!upg.objectStoreNames.contains(storeName)) upg.createObjectStore(storeName);
|
|
28
254
|
}
|
|
@@ -31,13 +257,13 @@ function createIndexedDBStorage(options) {
|
|
|
31
257
|
}
|
|
32
258
|
async function withRetry(fn) {
|
|
33
259
|
try {
|
|
34
|
-
const db = await
|
|
260
|
+
const db = await initDB();
|
|
35
261
|
return await fn(db);
|
|
36
262
|
} catch (err) {
|
|
37
263
|
const msg = String(err && err.message ? err.message : err);
|
|
38
264
|
if (err && (err.name === "NotFoundError" || /objectStore/i.test(msg))) {
|
|
39
265
|
await ensureStore();
|
|
40
|
-
const db2 = await
|
|
266
|
+
const db2 = await initDB();
|
|
41
267
|
return await fn(db2);
|
|
42
268
|
}
|
|
43
269
|
throw err;
|
|
@@ -74,7 +300,14 @@ var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
|
|
|
74
300
|
var DEFAULT_LOGGER = console;
|
|
75
301
|
var DEFAULT_MIN_LOG_LEVEL = "debug";
|
|
76
302
|
var DEFAULT_MISSING_REMOTE_RECORD_ON_UPDATE_STRATEGY = "ignore";
|
|
77
|
-
|
|
303
|
+
function createWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
304
|
+
const store = create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
store.persist.onFinishHydration((_state) => {
|
|
307
|
+
resolve(store);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
78
311
|
function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
79
312
|
const syncInterval = syncOptions.syncInterval ?? DEFAULT_SYNC_INTERVAL_MILLIS;
|
|
80
313
|
const missingStrategy = syncOptions.missingRemoteRecordDuringUpdateStrategy ?? DEFAULT_MISSING_REMOTE_RECORD_ON_UPDATE_STRATEGY;
|
|
@@ -84,13 +317,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
84
317
|
const wrappedPersistOptions = {
|
|
85
318
|
...persistOptions,
|
|
86
319
|
onRehydrateStorage: () => {
|
|
87
|
-
logger.debug("[
|
|
320
|
+
logger.debug("[Zync] Rehydration started");
|
|
88
321
|
return (state, error) => {
|
|
89
322
|
if (error) {
|
|
90
|
-
logger.error("[
|
|
323
|
+
logger.error("[Zync] Rehydration failed", error);
|
|
91
324
|
} else {
|
|
92
325
|
baseOnRehydrate?.(state, error);
|
|
93
|
-
logger.debug("[
|
|
326
|
+
logger.debug("[Zync] Rehydration complete", state);
|
|
94
327
|
}
|
|
95
328
|
};
|
|
96
329
|
},
|
|
@@ -107,12 +340,11 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
107
340
|
};
|
|
108
341
|
},
|
|
109
342
|
merge: (persisted, current) => {
|
|
343
|
+
const state = { ...current, ...persisted };
|
|
110
344
|
return {
|
|
111
|
-
...
|
|
112
|
-
...persisted,
|
|
345
|
+
...state,
|
|
113
346
|
syncState: {
|
|
114
|
-
...
|
|
115
|
-
...persisted?.syncState,
|
|
347
|
+
...state.syncState,
|
|
116
348
|
status: "idle"
|
|
117
349
|
// this confirms 'hydrating' is done
|
|
118
350
|
}
|
|
@@ -137,7 +369,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
137
369
|
await pull(set, get, stateKey, api, logger);
|
|
138
370
|
} catch (err) {
|
|
139
371
|
syncError = syncError ?? err;
|
|
140
|
-
logger.error(`[
|
|
372
|
+
logger.error(`[Zync] Pull error for stateKey: ${stateKey}`, err);
|
|
141
373
|
}
|
|
142
374
|
}
|
|
143
375
|
const snapshot = [...get().syncState.pendingChanges || []];
|
|
@@ -158,7 +390,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
158
390
|
);
|
|
159
391
|
} catch (err) {
|
|
160
392
|
syncError = syncError ?? err;
|
|
161
|
-
logger.error(`[
|
|
393
|
+
logger.error(`[Zync] Push error for change: ${change}`, err);
|
|
162
394
|
}
|
|
163
395
|
}
|
|
164
396
|
set((state2) => ({
|
|
@@ -172,40 +404,97 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
172
404
|
await syncOnce();
|
|
173
405
|
}
|
|
174
406
|
}
|
|
407
|
+
async function startFirstLoad() {
|
|
408
|
+
let syncError;
|
|
409
|
+
for (const stateKey of Object.keys(syncApi)) {
|
|
410
|
+
try {
|
|
411
|
+
logger.info(`[Zync] firstLoad:start for stateKey: ${stateKey}`);
|
|
412
|
+
const api = findApi(stateKey, syncApi);
|
|
413
|
+
let lastId;
|
|
414
|
+
while (true) {
|
|
415
|
+
const batch = await api.firstLoad(lastId);
|
|
416
|
+
if (!batch?.length) break;
|
|
417
|
+
set((state) => {
|
|
418
|
+
const local = state[stateKey] || [];
|
|
419
|
+
const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
|
|
420
|
+
let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
|
|
421
|
+
const next = [...local];
|
|
422
|
+
for (const remote of batch) {
|
|
423
|
+
const remoteUpdated = new Date(remote.updated_at || 0);
|
|
424
|
+
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
425
|
+
if (remote.deleted) continue;
|
|
426
|
+
const localItem = remote.id ? localById.get(remote.id) : void 0;
|
|
427
|
+
if (localItem) {
|
|
428
|
+
const merged = {
|
|
429
|
+
...localItem,
|
|
430
|
+
...remote,
|
|
431
|
+
_localId: localItem._localId
|
|
432
|
+
};
|
|
433
|
+
const idx = next.findIndex((i) => i._localId === localItem._localId);
|
|
434
|
+
if (idx >= 0) next[idx] = merged;
|
|
435
|
+
} else {
|
|
436
|
+
next.push({
|
|
437
|
+
...remote,
|
|
438
|
+
_localId: nextLocalId()
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
[stateKey]: next,
|
|
444
|
+
syncState: {
|
|
445
|
+
...state.syncState || {},
|
|
446
|
+
lastPulled: {
|
|
447
|
+
...state.syncState.lastPulled || {},
|
|
448
|
+
[stateKey]: newest.toISOString()
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
lastId = batch[batch.length - 1].id;
|
|
454
|
+
}
|
|
455
|
+
logger.info(`[Zync] firstLoad:done for stateKey: ${stateKey}`);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
syncError = syncError ?? err;
|
|
458
|
+
logger.error(`[Zync] First load pull error for stateKey: ${stateKey}`, err);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
set((state) => ({
|
|
462
|
+
syncState: {
|
|
463
|
+
...state.syncState || {},
|
|
464
|
+
firstLoadDone: true,
|
|
465
|
+
error: syncError
|
|
466
|
+
}
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
175
469
|
function queueToSync(action, stateKey, ...localIds) {
|
|
176
470
|
set((state) => {
|
|
177
|
-
const
|
|
471
|
+
const pendingChanges = state.syncState.pendingChanges || [];
|
|
178
472
|
for (const localId of localIds) {
|
|
179
473
|
const item = state[stateKey].find((i) => i._localId === localId);
|
|
180
474
|
if (!item) {
|
|
181
|
-
logger.error("[
|
|
182
|
-
stateKey,
|
|
183
|
-
localId
|
|
184
|
-
});
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
if (action === "remove" /* Remove */ && !item.id) {
|
|
188
|
-
logger.warn("[persistWithSync] queueToSync:remove-no-id", {
|
|
475
|
+
logger.error("[Zync] queueToSync:no-local-item", {
|
|
189
476
|
stateKey,
|
|
190
477
|
localId
|
|
191
478
|
});
|
|
192
479
|
continue;
|
|
193
480
|
}
|
|
194
|
-
const queueItem =
|
|
481
|
+
const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
195
482
|
if (queueItem) {
|
|
196
483
|
queueItem.version += 1;
|
|
197
484
|
if (queueItem.action === "createOrUpdate" /* CreateOrUpdate */ && action === "remove" /* Remove */ && item.id) {
|
|
198
485
|
queueItem.action = "remove" /* Remove */;
|
|
199
486
|
queueItem.id = item.id;
|
|
200
487
|
}
|
|
488
|
+
logger.debug(`[Zync] queueToSync:adjusted ${queueItem.version} ${action} ${item.id} ${stateKey} ${localId}`);
|
|
201
489
|
} else {
|
|
202
|
-
|
|
490
|
+
pendingChanges.push({ action, stateKey, localId, id: item.id, version: 1 });
|
|
491
|
+
logger.debug(`[Zync] queueToSync:added ${action} ${item.id} ${stateKey} ${localId}`);
|
|
203
492
|
}
|
|
204
493
|
}
|
|
205
494
|
return {
|
|
206
495
|
syncState: {
|
|
207
496
|
...state.syncState || {},
|
|
208
|
-
pendingChanges
|
|
497
|
+
pendingChanges
|
|
209
498
|
}
|
|
210
499
|
};
|
|
211
500
|
});
|
|
@@ -246,75 +535,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
246
535
|
}
|
|
247
536
|
function onVisibilityChange() {
|
|
248
537
|
if (document.visibilityState === "visible") {
|
|
249
|
-
logger.debug("[
|
|
538
|
+
logger.debug("[Zync] Sync started now app is in foreground");
|
|
250
539
|
enableSyncTimer(true);
|
|
251
540
|
} else {
|
|
252
|
-
logger.debug("[
|
|
541
|
+
logger.debug("[Zync] Sync paused now app is in background");
|
|
253
542
|
enableSyncTimer(false);
|
|
254
543
|
}
|
|
255
544
|
}
|
|
256
|
-
async function startFirstLoad() {
|
|
257
|
-
let syncError;
|
|
258
|
-
for (const stateKey of Object.keys(syncApi)) {
|
|
259
|
-
try {
|
|
260
|
-
logger.info(`[persistWithSync] firstLoad:start for stateKey: ${stateKey}`);
|
|
261
|
-
const api = findApi(stateKey, syncApi);
|
|
262
|
-
let lastId;
|
|
263
|
-
while (true) {
|
|
264
|
-
const batch = await api.firstLoad(lastId);
|
|
265
|
-
if (!batch?.length) break;
|
|
266
|
-
set((state) => {
|
|
267
|
-
const local = state[stateKey] || [];
|
|
268
|
-
const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
|
|
269
|
-
let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
|
|
270
|
-
const next = [...local];
|
|
271
|
-
for (const remote of batch) {
|
|
272
|
-
const remoteUpdated = new Date(remote.updated_at || 0);
|
|
273
|
-
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
274
|
-
if (remote.deleted) continue;
|
|
275
|
-
const localItem = remote.id ? localById.get(remote.id) : void 0;
|
|
276
|
-
if (localItem) {
|
|
277
|
-
const merged = {
|
|
278
|
-
...localItem,
|
|
279
|
-
...remote,
|
|
280
|
-
_localId: localItem._localId
|
|
281
|
-
};
|
|
282
|
-
const idx = next.findIndex((i) => i._localId === localItem._localId);
|
|
283
|
-
if (idx >= 0) next[idx] = merged;
|
|
284
|
-
} else {
|
|
285
|
-
next.push({
|
|
286
|
-
...remote,
|
|
287
|
-
_localId: nextLocalId()
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return {
|
|
292
|
-
[stateKey]: next,
|
|
293
|
-
syncState: {
|
|
294
|
-
...state.syncState || {},
|
|
295
|
-
lastPulled: {
|
|
296
|
-
...state.syncState.lastPulled || {},
|
|
297
|
-
[stateKey]: newest.toISOString()
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
});
|
|
302
|
-
lastId = batch[batch.length - 1].id;
|
|
303
|
-
}
|
|
304
|
-
logger.info(`[persistWithSync] firstLoad:done for stateKey: ${stateKey}`);
|
|
305
|
-
} catch (err) {
|
|
306
|
-
syncError = syncError ?? err;
|
|
307
|
-
logger.error(`[persistWithSync] First load pull error for stateKey: ${stateKey}`, err);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
set((state) => ({
|
|
311
|
-
syncState: {
|
|
312
|
-
...state.syncState || {},
|
|
313
|
-
firstLoadDone: true,
|
|
314
|
-
error: syncError
|
|
315
|
-
}
|
|
316
|
-
}));
|
|
317
|
-
}
|
|
318
545
|
storeApi.sync = {
|
|
319
546
|
enable,
|
|
320
547
|
startFirstLoad
|
|
@@ -335,219 +562,10 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
335
562
|
};
|
|
336
563
|
return persist(creator, wrappedPersistOptions);
|
|
337
564
|
}
|
|
338
|
-
function createStoreWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
339
|
-
return create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
|
|
340
|
-
}
|
|
341
|
-
function orderFor(a) {
|
|
342
|
-
switch (a) {
|
|
343
|
-
case "createOrUpdate" /* CreateOrUpdate */:
|
|
344
|
-
return 1;
|
|
345
|
-
case "remove" /* Remove */:
|
|
346
|
-
return 2;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
async function pull(set, get, stateKey, api, logger) {
|
|
350
|
-
const lastPulled = get().syncState.lastPulled || {};
|
|
351
|
-
const lastPulledAt = new Date(lastPulled[stateKey] || /* @__PURE__ */ new Date(0));
|
|
352
|
-
logger.debug(`[persistWithSync] pull:start stateKey=${stateKey} since=${lastPulledAt.toISOString()}`);
|
|
353
|
-
const serverData = await api.list(lastPulledAt);
|
|
354
|
-
if (!serverData?.length) return;
|
|
355
|
-
let newest = lastPulledAt;
|
|
356
|
-
set((state) => {
|
|
357
|
-
const pendingChanges = state.syncState.pendingChanges || [];
|
|
358
|
-
const localItems = state[stateKey] || [];
|
|
359
|
-
let nextItems = [...localItems];
|
|
360
|
-
const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
|
|
361
|
-
const pendingRemovalIds = /* @__PURE__ */ new Set();
|
|
362
|
-
for (const change of pendingChanges) {
|
|
363
|
-
if (change.stateKey === stateKey && change.action === "remove" /* Remove */) {
|
|
364
|
-
const item = localItems.find((i) => i._localId === change.localId);
|
|
365
|
-
if (item && item.id) pendingRemovalIds.add(item.id);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
for (const remote of serverData) {
|
|
369
|
-
const remoteUpdated = new Date(remote.updated_at);
|
|
370
|
-
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
371
|
-
const localItem = localById.get(remote.id);
|
|
372
|
-
if (pendingRemovalIds.has(remote.id)) {
|
|
373
|
-
logger.debug(`[persistWithSync] pull:skip-pending-remove stateKey=${stateKey} id=${remote.id}`);
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
if (remote.deleted) {
|
|
377
|
-
if (localItem) {
|
|
378
|
-
nextItems = nextItems.filter((i) => i.id !== remote.id);
|
|
379
|
-
logger.debug(`[persistWithSync] pull:remove stateKey=${stateKey} id=${remote.id}`);
|
|
380
|
-
}
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
|
|
384
|
-
if (localItem && !pending) {
|
|
385
|
-
const merged = {
|
|
386
|
-
...localItem,
|
|
387
|
-
...remote,
|
|
388
|
-
_localId: localItem._localId
|
|
389
|
-
};
|
|
390
|
-
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
391
|
-
logger.debug(`[persistWithSync] pull:merge stateKey=${stateKey} id=${remote.id}`);
|
|
392
|
-
} else if (!localItem) {
|
|
393
|
-
nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
|
|
394
|
-
logger.debug(`[persistWithSync] pull:add stateKey=${stateKey} id=${remote.id}`);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
[stateKey]: nextItems,
|
|
399
|
-
syncState: {
|
|
400
|
-
...state.syncState || {},
|
|
401
|
-
lastPulled: {
|
|
402
|
-
...state.syncState.lastPulled || {},
|
|
403
|
-
[stateKey]: newest.toISOString()
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
};
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
|
|
410
|
-
logger.debug(`[persistWithSync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
|
|
411
|
-
const { action, stateKey, localId, id, version } = change;
|
|
412
|
-
switch (action) {
|
|
413
|
-
case "remove" /* Remove */:
|
|
414
|
-
await api.remove(id);
|
|
415
|
-
logger.debug(`[persistWithSync] push:remove:success ${stateKey} ${localId} ${id}`);
|
|
416
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
417
|
-
break;
|
|
418
|
-
case "createOrUpdate" /* CreateOrUpdate */:
|
|
419
|
-
const state = get();
|
|
420
|
-
const items = state[stateKey] || [];
|
|
421
|
-
const item = items.find((i) => i._localId === localId);
|
|
422
|
-
if (!item) {
|
|
423
|
-
logger.warn(`[persistWithSync] push:${action}:no-local-item`, {
|
|
424
|
-
stateKey,
|
|
425
|
-
localId
|
|
426
|
-
});
|
|
427
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
let omittedItem = omitSyncFields(item);
|
|
431
|
-
if (item.id) {
|
|
432
|
-
const changed = await api.update(item.id, omittedItem);
|
|
433
|
-
if (changed) {
|
|
434
|
-
logger.debug("[persistWithSync] push:update:success", {
|
|
435
|
-
stateKey,
|
|
436
|
-
localId,
|
|
437
|
-
id: item.id
|
|
438
|
-
});
|
|
439
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
440
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
441
|
-
}
|
|
442
|
-
return;
|
|
443
|
-
} else {
|
|
444
|
-
logger.warn("[persistWithSync] push:update:missingRemote", {
|
|
445
|
-
stateKey,
|
|
446
|
-
localId,
|
|
447
|
-
id: item.id
|
|
448
|
-
});
|
|
449
|
-
switch (missingStrategy) {
|
|
450
|
-
case "deleteLocalRecord":
|
|
451
|
-
set((s) => ({
|
|
452
|
-
[stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
|
|
453
|
-
}));
|
|
454
|
-
break;
|
|
455
|
-
case "insertNewRemoteRecord": {
|
|
456
|
-
omittedItem._localId = nextLocalId();
|
|
457
|
-
omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
458
|
-
set((s) => ({
|
|
459
|
-
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
|
|
460
|
-
}));
|
|
461
|
-
queueToSync("createOrUpdate" /* CreateOrUpdate */, stateKey, omittedItem._localId);
|
|
462
|
-
break;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
466
|
-
onMissingRemoteRecordDuringUpdate?.(missingStrategy, omittedItem, omittedItem._localId);
|
|
467
|
-
}
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
const result = await api.add(omittedItem);
|
|
471
|
-
if (result) {
|
|
472
|
-
logger.debug("[persistWithSync] push:create:success", {
|
|
473
|
-
stateKey,
|
|
474
|
-
localId,
|
|
475
|
-
id: result.id
|
|
476
|
-
});
|
|
477
|
-
set((s) => ({
|
|
478
|
-
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
|
|
479
|
-
}));
|
|
480
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
481
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
482
|
-
}
|
|
483
|
-
onAfterRemoteAdd?.(set, get, queueToSync, stateKey, {
|
|
484
|
-
...item,
|
|
485
|
-
...result
|
|
486
|
-
});
|
|
487
|
-
} else {
|
|
488
|
-
logger.warn("[persistWithSync] push:create:no-result", {
|
|
489
|
-
stateKey,
|
|
490
|
-
localId
|
|
491
|
-
});
|
|
492
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
493
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
function samePendingVersion(get, stateKey, localId, version) {
|
|
500
|
-
const q = get().syncState.pendingChanges || [];
|
|
501
|
-
const curChange = q.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
502
|
-
return curChange?.version === version;
|
|
503
|
-
}
|
|
504
|
-
function removeFromPendingChanges(set, localId, stateKey) {
|
|
505
|
-
set((s) => {
|
|
506
|
-
const queue = (s.syncState.pendingChanges || []).filter((p) => !(p.localId === localId && p.stateKey === stateKey));
|
|
507
|
-
return {
|
|
508
|
-
syncState: {
|
|
509
|
-
...s.syncState || {},
|
|
510
|
-
pendingChanges: queue
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
function omitSyncFields(item) {
|
|
516
|
-
const result = { ...item };
|
|
517
|
-
for (const k of SYNC_FIELDS) delete result[k];
|
|
518
|
-
return result;
|
|
519
|
-
}
|
|
520
|
-
function nextLocalId() {
|
|
521
|
-
return crypto.randomUUID();
|
|
522
|
-
}
|
|
523
|
-
function newLogger(logger, min) {
|
|
524
|
-
const order = {
|
|
525
|
-
debug: 10,
|
|
526
|
-
info: 20,
|
|
527
|
-
warn: 30,
|
|
528
|
-
error: 40,
|
|
529
|
-
none: 100
|
|
530
|
-
};
|
|
531
|
-
const threshold = order[min];
|
|
532
|
-
const enabled = (lvl) => order[lvl] >= threshold;
|
|
533
|
-
return {
|
|
534
|
-
debug: (...a) => enabled("debug") && logger.debug?.(...a),
|
|
535
|
-
info: (...a) => enabled("info") && logger.info?.(...a),
|
|
536
|
-
warn: (...a) => enabled("warn") && logger.warn?.(...a),
|
|
537
|
-
error: (...a) => enabled("error") && logger.error?.(...a)
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
function findApi(stateKey, syncApi) {
|
|
541
|
-
const api = syncApi[stateKey];
|
|
542
|
-
if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
|
|
543
|
-
throw new Error(`Missing API function(s) for state key: ${stateKey}.`);
|
|
544
|
-
}
|
|
545
|
-
return api;
|
|
546
|
-
}
|
|
547
565
|
export {
|
|
548
566
|
SyncAction,
|
|
549
567
|
createIndexedDBStorage,
|
|
550
|
-
|
|
568
|
+
createWithSync,
|
|
551
569
|
nextLocalId,
|
|
552
570
|
persistWithSync
|
|
553
571
|
};
|