@anfenn/zync 0.1.8 → 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 +595 -329
- 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 +337 -325
- 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;
|
|
@@ -48,19 +274,16 @@ function createIndexedDBStorage(options) {
|
|
|
48
274
|
return withRetry(async (db) => {
|
|
49
275
|
let v = await db.get(storeName, name);
|
|
50
276
|
v = v ?? null;
|
|
51
|
-
console.log("getItem:", db.objectStoreNames, storeName, name, v);
|
|
52
277
|
return v;
|
|
53
278
|
});
|
|
54
279
|
},
|
|
55
280
|
setItem: async (name, value) => {
|
|
56
281
|
return withRetry(async (db) => {
|
|
57
|
-
console.log("setItem", name, value);
|
|
58
282
|
await db.put(storeName, value, name);
|
|
59
283
|
});
|
|
60
284
|
},
|
|
61
285
|
removeItem: async (name) => {
|
|
62
286
|
return withRetry(async (db) => {
|
|
63
|
-
console.log("removeItem", name);
|
|
64
287
|
await db.delete(storeName, name);
|
|
65
288
|
});
|
|
66
289
|
}
|
|
@@ -77,7 +300,14 @@ var DEFAULT_SYNC_INTERVAL_MILLIS = 5e3;
|
|
|
77
300
|
var DEFAULT_LOGGER = console;
|
|
78
301
|
var DEFAULT_MIN_LOG_LEVEL = "debug";
|
|
79
302
|
var DEFAULT_MISSING_REMOTE_RECORD_ON_UPDATE_STRATEGY = "ignore";
|
|
80
|
-
|
|
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
|
+
}
|
|
81
311
|
function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
82
312
|
const syncInterval = syncOptions.syncInterval ?? DEFAULT_SYNC_INTERVAL_MILLIS;
|
|
83
313
|
const missingStrategy = syncOptions.missingRemoteRecordDuringUpdateStrategy ?? DEFAULT_MISSING_REMOTE_RECORD_ON_UPDATE_STRATEGY;
|
|
@@ -87,14 +317,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
87
317
|
const wrappedPersistOptions = {
|
|
88
318
|
...persistOptions,
|
|
89
319
|
onRehydrateStorage: () => {
|
|
90
|
-
logger.debug("[
|
|
320
|
+
logger.debug("[Zync] Rehydration started");
|
|
91
321
|
return (state, error) => {
|
|
92
322
|
if (error) {
|
|
93
|
-
logger.error("[
|
|
323
|
+
logger.error("[Zync] Rehydration failed", error);
|
|
94
324
|
} else {
|
|
95
325
|
baseOnRehydrate?.(state, error);
|
|
96
|
-
|
|
97
|
-
logger.debug("[persistWithSync] Rehydration complete");
|
|
326
|
+
logger.debug("[Zync] Rehydration complete", state);
|
|
98
327
|
}
|
|
99
328
|
};
|
|
100
329
|
},
|
|
@@ -109,24 +338,18 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
109
338
|
lastPulled: syncState.lastPulled
|
|
110
339
|
}
|
|
111
340
|
};
|
|
341
|
+
},
|
|
342
|
+
merge: (persisted, current) => {
|
|
343
|
+
const state = { ...current, ...persisted };
|
|
344
|
+
return {
|
|
345
|
+
...state,
|
|
346
|
+
syncState: {
|
|
347
|
+
...state.syncState,
|
|
348
|
+
status: "idle"
|
|
349
|
+
// this confirms 'hydrating' is done
|
|
350
|
+
}
|
|
351
|
+
};
|
|
112
352
|
}
|
|
113
|
-
// merge: (persisted: any, current: any) => {
|
|
114
|
-
// // Add unpersistable fields back e.g. functions or memory-only fields
|
|
115
|
-
// const p = persisted || {};
|
|
116
|
-
// const c = current || {};
|
|
117
|
-
// return {
|
|
118
|
-
// ...c,
|
|
119
|
-
// ...p,
|
|
120
|
-
// syncState: {
|
|
121
|
-
// ...c.syncState,
|
|
122
|
-
// ...p.syncState,
|
|
123
|
-
// status: 'idle',
|
|
124
|
-
// //firstLoadDone: p.syncState?.firstLoadDone ?? c.syncState.firstLoadDone ?? false,
|
|
125
|
-
// //pendingChanges: p.syncState?.pendingChanges ?? c.syncState.pendingChanges ?? [],
|
|
126
|
-
// //lastPulled: p.syncState?.lastPulled ?? c.syncState.lastPulled ?? {},
|
|
127
|
-
// },
|
|
128
|
-
// };
|
|
129
|
-
// },
|
|
130
353
|
};
|
|
131
354
|
const creator = (set, get, storeApi) => {
|
|
132
355
|
let syncIntervalId;
|
|
@@ -146,7 +369,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
146
369
|
await pull(set, get, stateKey, api, logger);
|
|
147
370
|
} catch (err) {
|
|
148
371
|
syncError = syncError ?? err;
|
|
149
|
-
logger.error(`[
|
|
372
|
+
logger.error(`[Zync] Pull error for stateKey: ${stateKey}`, err);
|
|
150
373
|
}
|
|
151
374
|
}
|
|
152
375
|
const snapshot = [...get().syncState.pendingChanges || []];
|
|
@@ -167,7 +390,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
167
390
|
);
|
|
168
391
|
} catch (err) {
|
|
169
392
|
syncError = syncError ?? err;
|
|
170
|
-
logger.error(`[
|
|
393
|
+
logger.error(`[Zync] Push error for change: ${change}`, err);
|
|
171
394
|
}
|
|
172
395
|
}
|
|
173
396
|
set((state2) => ({
|
|
@@ -177,41 +400,101 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
177
400
|
error: syncError
|
|
178
401
|
}
|
|
179
402
|
}));
|
|
403
|
+
if (get().syncState.pendingChanges.length > 0 && !syncError) {
|
|
404
|
+
await syncOnce();
|
|
405
|
+
}
|
|
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
|
+
}));
|
|
180
468
|
}
|
|
181
469
|
function queueToSync(action, stateKey, ...localIds) {
|
|
182
470
|
set((state) => {
|
|
183
|
-
const
|
|
471
|
+
const pendingChanges = state.syncState.pendingChanges || [];
|
|
184
472
|
for (const localId of localIds) {
|
|
185
473
|
const item = state[stateKey].find((i) => i._localId === localId);
|
|
186
474
|
if (!item) {
|
|
187
|
-
logger.error("[
|
|
188
|
-
stateKey,
|
|
189
|
-
localId
|
|
190
|
-
});
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (action === "remove" /* Remove */ && !item.id) {
|
|
194
|
-
logger.warn("[persistWithSync] queueToSync:remove-no-id", {
|
|
475
|
+
logger.error("[Zync] queueToSync:no-local-item", {
|
|
195
476
|
stateKey,
|
|
196
477
|
localId
|
|
197
478
|
});
|
|
198
479
|
continue;
|
|
199
480
|
}
|
|
200
|
-
const queueItem =
|
|
481
|
+
const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
201
482
|
if (queueItem) {
|
|
202
483
|
queueItem.version += 1;
|
|
203
484
|
if (queueItem.action === "createOrUpdate" /* CreateOrUpdate */ && action === "remove" /* Remove */ && item.id) {
|
|
204
485
|
queueItem.action = "remove" /* Remove */;
|
|
205
486
|
queueItem.id = item.id;
|
|
206
487
|
}
|
|
488
|
+
logger.debug(`[Zync] queueToSync:adjusted ${queueItem.version} ${action} ${item.id} ${stateKey} ${localId}`);
|
|
207
489
|
} else {
|
|
208
|
-
|
|
490
|
+
pendingChanges.push({ action, stateKey, localId, id: item.id, version: 1 });
|
|
491
|
+
logger.debug(`[Zync] queueToSync:added ${action} ${item.id} ${stateKey} ${localId}`);
|
|
209
492
|
}
|
|
210
493
|
}
|
|
211
494
|
return {
|
|
212
495
|
syncState: {
|
|
213
496
|
...state.syncState || {},
|
|
214
|
-
pendingChanges
|
|
497
|
+
pendingChanges
|
|
215
498
|
}
|
|
216
499
|
};
|
|
217
500
|
});
|
|
@@ -252,75 +535,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
252
535
|
}
|
|
253
536
|
function onVisibilityChange() {
|
|
254
537
|
if (document.visibilityState === "visible") {
|
|
255
|
-
logger.debug("[
|
|
538
|
+
logger.debug("[Zync] Sync started now app is in foreground");
|
|
256
539
|
enableSyncTimer(true);
|
|
257
540
|
} else {
|
|
258
|
-
logger.debug("[
|
|
541
|
+
logger.debug("[Zync] Sync paused now app is in background");
|
|
259
542
|
enableSyncTimer(false);
|
|
260
543
|
}
|
|
261
544
|
}
|
|
262
|
-
async function startFirstLoad() {
|
|
263
|
-
let syncError;
|
|
264
|
-
for (const stateKey of Object.keys(syncApi)) {
|
|
265
|
-
try {
|
|
266
|
-
logger.info(`[persistWithSync] firstLoad:start for stateKey: ${stateKey}`);
|
|
267
|
-
const api = findApi(stateKey, syncApi);
|
|
268
|
-
let lastId;
|
|
269
|
-
while (true) {
|
|
270
|
-
const batch = await api.firstLoad(lastId);
|
|
271
|
-
if (!batch?.length) break;
|
|
272
|
-
set((state) => {
|
|
273
|
-
const local = state[stateKey] || [];
|
|
274
|
-
const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
|
|
275
|
-
let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
|
|
276
|
-
const next = [...local];
|
|
277
|
-
for (const remote of batch) {
|
|
278
|
-
const remoteUpdated = new Date(remote.updated_at || 0);
|
|
279
|
-
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
280
|
-
if (remote.deleted) continue;
|
|
281
|
-
const localItem = remote.id ? localById.get(remote.id) : void 0;
|
|
282
|
-
if (localItem) {
|
|
283
|
-
const merged = {
|
|
284
|
-
...localItem,
|
|
285
|
-
...remote,
|
|
286
|
-
_localId: localItem._localId
|
|
287
|
-
};
|
|
288
|
-
const idx = next.findIndex((i) => i._localId === localItem._localId);
|
|
289
|
-
if (idx >= 0) next[idx] = merged;
|
|
290
|
-
} else {
|
|
291
|
-
next.push({
|
|
292
|
-
...remote,
|
|
293
|
-
_localId: nextLocalId()
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
return {
|
|
298
|
-
[stateKey]: next,
|
|
299
|
-
syncState: {
|
|
300
|
-
...state.syncState || {},
|
|
301
|
-
lastPulled: {
|
|
302
|
-
...state.syncState.lastPulled || {},
|
|
303
|
-
[stateKey]: newest.toISOString()
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
});
|
|
308
|
-
lastId = batch[batch.length - 1].id;
|
|
309
|
-
}
|
|
310
|
-
logger.info(`[persistWithSync] firstLoad:done for stateKey: ${stateKey}`);
|
|
311
|
-
} catch (err) {
|
|
312
|
-
syncError = syncError ?? err;
|
|
313
|
-
logger.error(`[persistWithSync] First load pull error for stateKey: ${stateKey}`, err);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
set((state) => ({
|
|
317
|
-
syncState: {
|
|
318
|
-
...state.syncState || {},
|
|
319
|
-
firstLoadDone: true,
|
|
320
|
-
error: syncError
|
|
321
|
-
}
|
|
322
|
-
}));
|
|
323
|
-
}
|
|
324
545
|
storeApi.sync = {
|
|
325
546
|
enable,
|
|
326
547
|
startFirstLoad
|
|
@@ -341,219 +562,10 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
|
|
|
341
562
|
};
|
|
342
563
|
return persist(creator, wrappedPersistOptions);
|
|
343
564
|
}
|
|
344
|
-
function createStoreWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
|
|
345
|
-
return create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
|
|
346
|
-
}
|
|
347
|
-
function orderFor(a) {
|
|
348
|
-
switch (a) {
|
|
349
|
-
case "createOrUpdate" /* CreateOrUpdate */:
|
|
350
|
-
return 1;
|
|
351
|
-
case "remove" /* Remove */:
|
|
352
|
-
return 2;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
async function pull(set, get, stateKey, api, logger) {
|
|
356
|
-
const lastPulled = get().syncState.lastPulled || {};
|
|
357
|
-
const lastPulledAt = new Date(lastPulled[stateKey] || /* @__PURE__ */ new Date(0));
|
|
358
|
-
logger.debug(`[persistWithSync] pull:start stateKey=${stateKey} since=${lastPulledAt.toISOString()}`);
|
|
359
|
-
const serverData = await api.list(lastPulledAt);
|
|
360
|
-
if (!serverData?.length) return;
|
|
361
|
-
let newest = lastPulledAt;
|
|
362
|
-
set((state) => {
|
|
363
|
-
const pendingChanges = state.syncState.pendingChanges || [];
|
|
364
|
-
const localItems = state[stateKey] || [];
|
|
365
|
-
let nextItems = [...localItems];
|
|
366
|
-
const localById = new Map(localItems.filter((l) => l.id).map((l) => [l.id, l]));
|
|
367
|
-
const pendingRemovalIds = /* @__PURE__ */ new Set();
|
|
368
|
-
for (const change of pendingChanges) {
|
|
369
|
-
if (change.stateKey === stateKey && change.action === "remove" /* Remove */) {
|
|
370
|
-
const item = localItems.find((i) => i._localId === change.localId);
|
|
371
|
-
if (item && item.id) pendingRemovalIds.add(item.id);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
for (const remote of serverData) {
|
|
375
|
-
const remoteUpdated = new Date(remote.updated_at);
|
|
376
|
-
if (remoteUpdated > newest) newest = remoteUpdated;
|
|
377
|
-
const localItem = localById.get(remote.id);
|
|
378
|
-
if (pendingRemovalIds.has(remote.id)) {
|
|
379
|
-
logger.debug(`[persistWithSync] pull:skip-pending-remove stateKey=${stateKey} id=${remote.id}`);
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
if (remote.deleted) {
|
|
383
|
-
if (localItem) {
|
|
384
|
-
nextItems = nextItems.filter((i) => i.id !== remote.id);
|
|
385
|
-
logger.debug(`[persistWithSync] pull:remove stateKey=${stateKey} id=${remote.id}`);
|
|
386
|
-
}
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
|
|
390
|
-
if (localItem && !pending) {
|
|
391
|
-
const merged = {
|
|
392
|
-
...localItem,
|
|
393
|
-
...remote,
|
|
394
|
-
_localId: localItem._localId
|
|
395
|
-
};
|
|
396
|
-
nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
|
|
397
|
-
logger.debug(`[persistWithSync] pull:merge stateKey=${stateKey} id=${remote.id}`);
|
|
398
|
-
} else if (!localItem) {
|
|
399
|
-
nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
|
|
400
|
-
logger.debug(`[persistWithSync] pull:add stateKey=${stateKey} id=${remote.id}`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return {
|
|
404
|
-
[stateKey]: nextItems,
|
|
405
|
-
syncState: {
|
|
406
|
-
...state.syncState || {},
|
|
407
|
-
lastPulled: {
|
|
408
|
-
...state.syncState.lastPulled || {},
|
|
409
|
-
[stateKey]: newest.toISOString()
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
|
|
416
|
-
logger.debug(`[persistWithSync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
|
|
417
|
-
const { action, stateKey, localId, id, version } = change;
|
|
418
|
-
switch (action) {
|
|
419
|
-
case "remove" /* Remove */:
|
|
420
|
-
await api.remove(id);
|
|
421
|
-
logger.debug(`[persistWithSync] push:remove:success ${stateKey} ${localId} ${id}`);
|
|
422
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
423
|
-
break;
|
|
424
|
-
case "createOrUpdate" /* CreateOrUpdate */:
|
|
425
|
-
const state = get();
|
|
426
|
-
const items = state[stateKey] || [];
|
|
427
|
-
const item = items.find((i) => i._localId === localId);
|
|
428
|
-
if (!item) {
|
|
429
|
-
logger.warn(`[persistWithSync] push:${action}:no-local-item`, {
|
|
430
|
-
stateKey,
|
|
431
|
-
localId
|
|
432
|
-
});
|
|
433
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
let omittedItem = omitSyncFields(item);
|
|
437
|
-
if (item.id) {
|
|
438
|
-
const changed = await api.update(item.id, omittedItem);
|
|
439
|
-
if (changed) {
|
|
440
|
-
logger.debug("[persistWithSync] push:update:success", {
|
|
441
|
-
stateKey,
|
|
442
|
-
localId,
|
|
443
|
-
id: item.id
|
|
444
|
-
});
|
|
445
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
446
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
447
|
-
}
|
|
448
|
-
return;
|
|
449
|
-
} else {
|
|
450
|
-
logger.warn("[persistWithSync] push:update:missingRemote", {
|
|
451
|
-
stateKey,
|
|
452
|
-
localId,
|
|
453
|
-
id: item.id
|
|
454
|
-
});
|
|
455
|
-
switch (missingStrategy) {
|
|
456
|
-
case "deleteLocalRecord":
|
|
457
|
-
set((s) => ({
|
|
458
|
-
[stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
|
|
459
|
-
}));
|
|
460
|
-
break;
|
|
461
|
-
case "insertNewRemoteRecord": {
|
|
462
|
-
omittedItem._localId = nextLocalId();
|
|
463
|
-
omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
464
|
-
set((s) => ({
|
|
465
|
-
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
|
|
466
|
-
}));
|
|
467
|
-
queueToSync("createOrUpdate" /* CreateOrUpdate */, stateKey, omittedItem._localId);
|
|
468
|
-
break;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
472
|
-
onMissingRemoteRecordDuringUpdate?.(missingStrategy, omittedItem, omittedItem._localId);
|
|
473
|
-
}
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
const result = await api.add(omittedItem);
|
|
477
|
-
if (result) {
|
|
478
|
-
logger.debug("[persistWithSync] push:create:success", {
|
|
479
|
-
stateKey,
|
|
480
|
-
localId,
|
|
481
|
-
id: result.id
|
|
482
|
-
});
|
|
483
|
-
set((s) => ({
|
|
484
|
-
[stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
|
|
485
|
-
}));
|
|
486
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
487
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
488
|
-
}
|
|
489
|
-
onAfterRemoteAdd?.(set, get, queueToSync, stateKey, {
|
|
490
|
-
...item,
|
|
491
|
-
...result
|
|
492
|
-
});
|
|
493
|
-
} else {
|
|
494
|
-
logger.warn("[persistWithSync] push:create:no-result", {
|
|
495
|
-
stateKey,
|
|
496
|
-
localId
|
|
497
|
-
});
|
|
498
|
-
if (samePendingVersion(get, stateKey, localId, version)) {
|
|
499
|
-
removeFromPendingChanges(set, localId, stateKey);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
function samePendingVersion(get, stateKey, localId, version) {
|
|
506
|
-
const q = get().syncState.pendingChanges || [];
|
|
507
|
-
const curChange = q.find((p) => p.localId === localId && p.stateKey === stateKey);
|
|
508
|
-
return curChange?.version === version;
|
|
509
|
-
}
|
|
510
|
-
function removeFromPendingChanges(set, localId, stateKey) {
|
|
511
|
-
set((s) => {
|
|
512
|
-
const queue = (s.syncState.pendingChanges || []).filter((p) => !(p.localId === localId && p.stateKey === stateKey));
|
|
513
|
-
return {
|
|
514
|
-
syncState: {
|
|
515
|
-
...s.syncState || {},
|
|
516
|
-
pendingChanges: queue
|
|
517
|
-
}
|
|
518
|
-
};
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
function omitSyncFields(item) {
|
|
522
|
-
const result = { ...item };
|
|
523
|
-
for (const k of SYNC_FIELDS) delete result[k];
|
|
524
|
-
return result;
|
|
525
|
-
}
|
|
526
|
-
function nextLocalId() {
|
|
527
|
-
return crypto.randomUUID();
|
|
528
|
-
}
|
|
529
|
-
function newLogger(logger, min) {
|
|
530
|
-
const order = {
|
|
531
|
-
debug: 10,
|
|
532
|
-
info: 20,
|
|
533
|
-
warn: 30,
|
|
534
|
-
error: 40,
|
|
535
|
-
none: 100
|
|
536
|
-
};
|
|
537
|
-
const threshold = order[min];
|
|
538
|
-
const enabled = (lvl) => order[lvl] >= threshold;
|
|
539
|
-
return {
|
|
540
|
-
debug: (...a) => enabled("debug") && logger.debug?.(...a),
|
|
541
|
-
info: (...a) => enabled("info") && logger.info?.(...a),
|
|
542
|
-
warn: (...a) => enabled("warn") && logger.warn?.(...a),
|
|
543
|
-
error: (...a) => enabled("error") && logger.error?.(...a)
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
function findApi(stateKey, syncApi) {
|
|
547
|
-
const api = syncApi[stateKey];
|
|
548
|
-
if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
|
|
549
|
-
throw new Error(`Missing API function(s) for state key: ${stateKey}.`);
|
|
550
|
-
}
|
|
551
|
-
return api;
|
|
552
|
-
}
|
|
553
565
|
export {
|
|
554
566
|
SyncAction,
|
|
555
567
|
createIndexedDBStorage,
|
|
556
|
-
|
|
568
|
+
createWithSync,
|
|
557
569
|
nextLocalId,
|
|
558
570
|
persistWithSync
|
|
559
571
|
};
|