@anfenn/zync 0.1.9 → 0.1.11
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 +586 -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 -311
- 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:create-or-update: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:missing-remote", {
|
|
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:start");
|
|
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 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 change=${change}`, err);
|
|
162
394
|
}
|
|
163
395
|
}
|
|
164
396
|
set((state2) => ({
|
|
@@ -172,40 +404,94 @@ 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 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 stateKey=${stateKey}`);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
syncError = syncError ?? err;
|
|
458
|
+
logger.error(`[zync] firstLoad:error 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", {
|
|
189
|
-
stateKey,
|
|
190
|
-
localId
|
|
191
|
-
});
|
|
475
|
+
logger.error(`[zync] queueToSync:no-local-item localId=${localId}`);
|
|
192
476
|
continue;
|
|
193
477
|
}
|
|
194
|
-
const queueItem =
|
|
478
|
+
const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
195
479
|
if (queueItem) {
|
|
196
480
|
queueItem.version += 1;
|
|
197
481
|
if (queueItem.action === "createOrUpdate" /* CreateOrUpdate */ && action === "remove" /* Remove */ && item.id) {
|
|
198
482
|
queueItem.action = "remove" /* Remove */;
|
|
199
483
|
queueItem.id = item.id;
|
|
200
484
|
}
|
|
485
|
+
logger.debug(`[zync] queueToSync:adjusted v=${queueItem.version} action=${action} localId=${localId}`);
|
|
201
486
|
} else {
|
|
202
|
-
|
|
487
|
+
pendingChanges.push({ action, stateKey, localId, id: item.id, version: 1 });
|
|
488
|
+
logger.debug(`[zync] queueToSync:added action=${action} localId=${localId}`);
|
|
203
489
|
}
|
|
204
490
|
}
|
|
205
491
|
return {
|
|
206
492
|
syncState: {
|
|
207
493
|
...state.syncState || {},
|
|
208
|
-
pendingChanges
|
|
494
|
+
pendingChanges
|
|
209
495
|
}
|
|
210
496
|
};
|
|
211
497
|
});
|
|
@@ -246,75 +532,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
246
532
|
}
|
|
247
533
|
function onVisibilityChange() {
|
|
248
534
|
if (document.visibilityState === "visible") {
|
|
249
|
-
logger.debug("[
|
|
535
|
+
logger.debug("[zync] sync:start-in-foreground");
|
|
250
536
|
enableSyncTimer(true);
|
|
251
537
|
} else {
|
|
252
|
-
logger.debug("[
|
|
538
|
+
logger.debug("[zync] sync:pause-in-background");
|
|
253
539
|
enableSyncTimer(false);
|
|
254
540
|
}
|
|
255
541
|
}
|
|
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
542
|
storeApi.sync = {
|
|
319
543
|
enable,
|
|
320
544
|
startFirstLoad
|
|
@@ -335,219 +559,10 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
335
559
|
};
|
|
336
560
|
return persist(creator, wrappedPersistOptions);
|
|
337
561
|
}
|
|
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
562
|
export {
|
|
548
563
|
SyncAction,
|
|
549
564
|
createIndexedDBStorage,
|
|
550
|
-
|
|
565
|
+
createWithSync,
|
|
551
566
|
nextLocalId,
|
|
552
567
|
persistWithSync
|
|
553
568
|
};
|