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