@anfenn/zync 0.1.4 → 0.1.5

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.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
- function createIndexedDBStorage(options = {}) {
7
- if (typeof globalThis.localStorage === "undefined") {
8
- const mem = {};
9
- globalThis.localStorage = {
10
- getItem: (k) => k in mem ? mem[k] : null,
11
- setItem: (k, v) => {
12
- mem[k] = v;
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
- return new Promise((resolve, reject) => {
63
- let tx;
64
- try {
65
- tx = db.transaction(storeName, mode);
66
- } catch (_e) {
67
- dbPromise = void 0;
68
- openDB().then((fresh) => {
69
- try {
70
- if (!fresh) {
71
- const result3 = fn(null);
72
- Promise.resolve(result3).then(resolve, reject);
73
- return;
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 removeItem(name) {
124
- return withStore("readwrite", (store) => {
125
- if (!store) {
126
- localStorage.removeItem(name);
127
- return;
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
- return new Promise((resolve, reject) => {
130
- const req = store.delete(name);
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: (n) => Promise.resolve(ls?.getItem ? ls.getItem(n) : null),
142
- setItem: (n, v) => {
143
- ls?.setItem?.(n, v);
144
- return Promise.resolve();
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
- removeItem: (n) => {
147
- ls?.removeItem?.(n);
148
- return Promise.resolve();
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["Create"] = "create";
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 = "insertNewRemoteRecord";
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: _sync, ...rest } = base || {};
103
+ const { syncState, ...rest } = base || {};
190
104
  return {
191
105
  ...rest,
192
106
  syncState: {
193
- firstLoadDone: _sync?.firstLoadDone ?? false
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
- return {
217
- syncState: {
218
- ...state2.syncState,
219
- status: "syncing"
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()._pendingChanges || []];
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,95 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
251
170
  logger.error(`[persistWithSync] Push error for change: ${change}`, err);
252
171
  }
253
172
  }
254
- set((state2) => {
255
- return {
256
- syncState: {
257
- ...state2.syncState,
258
- status: "idle",
259
- error: syncError
260
- }
261
- };
262
- });
173
+ set((state2) => ({
174
+ syncState: {
175
+ ...state2.syncState || {},
176
+ status: "idle",
177
+ error: syncError
178
+ }
179
+ }));
263
180
  }
264
- startSync = async () => {
265
- clearInterval(syncIntervalId);
266
- syncIntervalId = void 0;
267
- await syncOnce();
268
- syncIntervalId = setInterval(syncOnce, syncInterval);
269
- };
270
- function queueToSync(action, localId, stateKey, changes) {
181
+ function queueToSync(action, stateKey, ...localIds) {
271
182
  set((state) => {
272
- const queue = state._pendingChanges || [];
273
- const idx = queue.findIndex((p) => p.localId === localId && p.stateKey === stateKey);
274
- if (idx >= 0) {
275
- const existing = queue[idx];
276
- switch (existing?.action) {
277
- case "create" /* Create */:
278
- if (action === "update" /* Update */) {
279
- existing.changes = { ...existing.changes, ...changes };
280
- } else if (action === "remove" /* Remove */) {
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;
183
+ const queue = state.syncState.pendingChanges || [];
184
+ for (const localId of localIds) {
185
+ const item = state[stateKey].find((i) => i._localId === localId);
186
+ if (!item) {
187
+ logger.error("[persistWithSync] queueToSync:no-local-item", {
188
+ stateKey,
189
+ localId
190
+ });
191
+ continue;
294
192
  }
295
- } else {
296
- if (action === "remove" /* Remove */) {
297
- const item = state[stateKey].find((i) => i._localId === localId);
298
- if (item) changes = { id: item.id };
193
+ if (action === "remove" /* Remove */ && !item.id) {
194
+ logger.warn("[persistWithSync] queueToSync:remove-no-id", {
195
+ stateKey,
196
+ localId
197
+ });
198
+ continue;
199
+ }
200
+ const queueItem = queue.find((p) => p.localId === localId && p.stateKey === stateKey);
201
+ if (queueItem) {
202
+ queueItem.version += 1;
203
+ if (queueItem.action === "createOrUpdate" /* CreateOrUpdate */ && action === "remove" /* Remove */ && item.id) {
204
+ queueItem.action = "remove" /* Remove */;
205
+ queueItem.id = item.id;
206
+ }
207
+ } else {
208
+ queue.push({ action, stateKey, localId, id: item.id, version: 1 });
299
209
  }
300
- queue.push({ stateKey, localId, action, changes });
301
210
  }
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
211
  return {
314
212
  syncState: {
315
- ...state.syncState,
316
- enabled
213
+ ...state.syncState || {},
214
+ pendingChanges: queue
317
215
  }
318
216
  };
319
217
  });
320
218
  syncOnce();
321
219
  }
322
- document.addEventListener("visibilitychange", async () => {
220
+ function setAndSync(partial) {
221
+ if (typeof partial === "function") {
222
+ set((state) => ({ ...partial(state) }));
223
+ } else {
224
+ set(partial);
225
+ }
226
+ syncOnce();
227
+ }
228
+ function enable(enabled) {
229
+ set((state) => ({
230
+ syncState: {
231
+ ...state.syncState || {},
232
+ enabled
233
+ }
234
+ }));
235
+ enableSyncTimer(enabled);
236
+ addVisibilityChangeListener(enabled);
237
+ }
238
+ function enableSyncTimer(enabled) {
323
239
  clearInterval(syncIntervalId);
240
+ syncIntervalId = void 0;
241
+ if (enabled) {
242
+ syncIntervalId = setInterval(syncOnce, syncInterval);
243
+ syncOnce();
244
+ }
245
+ }
246
+ function addVisibilityChangeListener(add) {
247
+ if (add) {
248
+ document.addEventListener("visibilitychange", onVisibilityChange);
249
+ } else {
250
+ document.removeEventListener("visibilitychange", onVisibilityChange);
251
+ }
252
+ }
253
+ function onVisibilityChange() {
324
254
  if (document.visibilityState === "visible") {
325
- logger.debug("[persistWithSync] Sync starting now app is in foreground");
326
- await startSync?.();
255
+ logger.debug("[persistWithSync] Sync started now app is in foreground");
256
+ enableSyncTimer(true);
327
257
  } else {
328
258
  logger.debug("[persistWithSync] Sync paused now app is in background");
259
+ enableSyncTimer(false);
329
260
  }
330
- });
261
+ }
331
262
  async function startFirstLoad() {
332
263
  let syncError;
333
264
  for (const stateKey of Object.keys(syncApi)) {
@@ -341,7 +272,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
341
272
  set((state) => {
342
273
  const local = state[stateKey] || [];
343
274
  const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
344
- let newest = new Date(state._lastPulled?.[stateKey] || 0);
275
+ let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
345
276
  const next = [...local];
346
277
  for (const remote of batch) {
347
278
  const remoteUpdated = new Date(remote.updated_at || 0);
@@ -349,19 +280,30 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
349
280
  if (remote.deleted) continue;
350
281
  const localItem = remote.id ? localById.get(remote.id) : void 0;
351
282
  if (localItem) {
352
- const merged = { ...localItem, ...remote, _localId: localItem._localId };
283
+ const merged = {
284
+ ...localItem,
285
+ ...remote,
286
+ _localId: localItem._localId
287
+ };
353
288
  const idx = next.findIndex((i) => i._localId === localItem._localId);
354
289
  if (idx >= 0) next[idx] = merged;
355
290
  } else {
356
- next.push({ ...remote, _localId: nextLocalId() });
291
+ next.push({
292
+ ...remote,
293
+ _localId: nextLocalId()
294
+ });
357
295
  }
358
296
  }
359
- state[stateKey] = next;
360
- state._lastPulled = {
361
- ...state._lastPulled || {},
362
- [stateKey]: newest.toISOString()
297
+ return {
298
+ [stateKey]: next,
299
+ syncState: {
300
+ ...state.syncState || {},
301
+ lastPulled: {
302
+ ...state.syncState.lastPulled || {},
303
+ [stateKey]: newest.toISOString()
304
+ }
305
+ }
363
306
  };
364
- return state;
365
307
  });
366
308
  lastId = batch[batch.length - 1].id;
367
309
  }
@@ -371,204 +313,209 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
371
313
  logger.error(`[persistWithSync] First load pull error for stateKey: ${stateKey}`, err);
372
314
  }
373
315
  }
374
- set((state) => {
375
- return {
376
- syncState: { ...state.syncState, firstLoadDone: true },
377
- syncError
378
- };
379
- });
316
+ set((state) => ({
317
+ syncState: {
318
+ ...state.syncState || {},
319
+ firstLoadDone: true,
320
+ error: syncError
321
+ }
322
+ }));
380
323
  }
324
+ storeApi.sync = {
325
+ enable,
326
+ startFirstLoad
327
+ };
381
328
  const userState = stateCreator(setAndSync, get, queueToSync);
382
- const syncState = userState.syncState || {};
383
329
  return {
384
330
  ...userState,
385
331
  syncState: {
386
- status: syncState.status ?? "hydrating",
387
- error: syncState.error,
388
- enabled: syncState.enabled ?? false,
389
- firstLoadDone: syncState.firstLoadDone ?? false,
390
- enableSync,
391
- startFirstLoad
392
- },
393
- _pendingChanges: userState._pendingChanges ?? [],
394
- _lastPulled: userState._lastPulled ?? {}
332
+ // set defaults
333
+ status: "hydrating",
334
+ error: void 0,
335
+ enabled: false,
336
+ firstLoadDone: false,
337
+ pendingChanges: [],
338
+ lastPulled: {}
339
+ }
395
340
  };
396
341
  };
397
342
  return persist(creator, wrappedPersistOptions);
398
343
  }
344
+ function createStoreWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}) {
345
+ return create(persistWithSync(stateCreator, persistOptions, syncApi, syncOptions));
346
+ }
399
347
  function orderFor(a) {
400
348
  switch (a) {
401
- case "create" /* Create */:
349
+ case "createOrUpdate" /* CreateOrUpdate */:
402
350
  return 1;
403
- case "update" /* Update */:
404
- return 2;
405
351
  case "remove" /* Remove */:
406
- return 3;
352
+ return 2;
407
353
  }
408
354
  }
409
355
  async function pull(set, get, stateKey, api, logger) {
410
- const lastPulled = get()._lastPulled || {};
356
+ const lastPulled = get().syncState.lastPulled || {};
411
357
  const lastPulledAt = new Date(lastPulled[stateKey] || /* @__PURE__ */ new Date(0));
412
358
  logger.debug(`[persistWithSync] pull:start stateKey=${stateKey} since=${lastPulledAt.toISOString()}`);
413
359
  const serverData = await api.list(lastPulledAt);
414
360
  if (!serverData?.length) return;
415
361
  let newest = lastPulledAt;
416
362
  set((state) => {
417
- const _pendingChanges = state._pendingChanges || [];
418
- const local = state[stateKey] || [];
419
- const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
420
- const pendingRemovals = new Set(
421
- _pendingChanges.filter(
422
- (p) => p.stateKey === stateKey && p.action === "remove" /* Remove */ && p.changes && typeof p.changes.id !== "undefined"
423
- ).map((p) => p.changes.id)
424
- );
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
+ }
425
374
  for (const remote of serverData) {
426
375
  const remoteUpdated = new Date(remote.updated_at);
427
376
  if (remoteUpdated > newest) newest = remoteUpdated;
428
377
  const localItem = localById.get(remote.id);
429
- if (pendingRemovals.has(remote.id)) {
378
+ if (pendingRemovalIds.has(remote.id)) {
430
379
  logger.debug(`[persistWithSync] pull:skip-pending-remove stateKey=${stateKey} id=${remote.id}`);
431
380
  continue;
432
381
  }
433
382
  if (remote.deleted) {
434
383
  if (localItem) {
435
- state[stateKey] = state[stateKey].filter((i) => i.id !== remote.id);
384
+ nextItems = nextItems.filter((i) => i.id !== remote.id);
436
385
  logger.debug(`[persistWithSync] pull:remove stateKey=${stateKey} id=${remote.id}`);
437
386
  }
438
387
  continue;
439
388
  }
440
- const pending = _pendingChanges.some(
441
- (p) => p.stateKey === stateKey && localItem && p.localId === localItem._localId
442
- );
389
+ const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
443
390
  if (localItem && !pending) {
444
- const merged = { ...localItem, ...remote, _localId: localItem._localId };
445
- state[stateKey] = state[stateKey].map((i) => i._localId === localItem._localId ? merged : i);
391
+ const merged = {
392
+ ...localItem,
393
+ ...remote,
394
+ _localId: localItem._localId
395
+ };
396
+ nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
446
397
  logger.debug(`[persistWithSync] pull:merge stateKey=${stateKey} id=${remote.id}`);
447
398
  } else if (!localItem) {
448
- state[stateKey] = [...state[stateKey], { ...remote, _localId: nextLocalId() }];
399
+ nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
449
400
  logger.debug(`[persistWithSync] pull:add stateKey=${stateKey} id=${remote.id}`);
450
401
  }
451
402
  }
452
- state._lastPulled = {
453
- ...state._lastPulled,
454
- [stateKey]: newest.toISOString()
403
+ return {
404
+ [stateKey]: nextItems,
405
+ syncState: {
406
+ ...state.syncState || {},
407
+ lastPulled: {
408
+ ...state.syncState.lastPulled || {},
409
+ [stateKey]: newest.toISOString()
410
+ }
411
+ }
455
412
  };
456
- return state;
457
413
  });
458
414
  }
459
415
  async function pushOne(set, get, change, api, logger, queueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
460
- logger.debug(
461
- `[persistWithSync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`
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);
416
+ logger.debug(`[persistWithSync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
417
+ const { action, stateKey, localId, id, version } = change;
467
418
  switch (action) {
468
- case "create" /* Create */: {
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);
469
428
  if (!item) {
429
+ logger.warn(`[persistWithSync] push:${action}:no-local-item`, {
430
+ stateKey,
431
+ localId
432
+ });
470
433
  removeFromPendingChanges(set, localId, stateKey);
471
434
  return;
472
435
  }
436
+ let omittedItem = omitSyncFields(item);
473
437
  if (item.id) {
474
- if (changes && Object.keys(changes).length) {
475
- await api.update(item.id, omitSyncFields({ ...item, ...changes }));
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);
476
473
  }
477
- removeFromPendingChanges(set, localId, stateKey);
478
474
  return;
479
475
  }
480
- const payload = changes ? { ...item, ...changes } : item;
481
- const result = await api.add(omitSyncFields(payload));
476
+ const result = await api.add(omittedItem);
482
477
  if (result) {
483
- logger.debug("[persistWithSync] push:create:success", { stateKey, localId, id: result.id });
484
- set((s) => {
485
- s[stateKey] = (s[stateKey] || []).map(
486
- (i) => i._localId === localId ? { ...i, ...result } : i
487
- );
488
- return s;
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
489
492
  });
490
- onAfterRemoteAdd?.(set, get, queueToSync, stateKey, { ...item, ...result });
491
493
  } else {
492
- logger.warn("[persistWithSync] push:create:no-result", { stateKey, localId });
493
- }
494
- removeFromPendingChanges(set, localId, stateKey);
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;
494
+ logger.warn("[persistWithSync] push:create:no-result", {
495
+ stateKey,
496
+ localId
508
497
  });
509
- return;
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
- }
498
+ if (samePendingVersion(get, stateKey, localId, version)) {
499
+ removeFromPendingChanges(set, localId, stateKey);
547
500
  }
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
501
  }
561
- removeFromPendingChanges(set, localId, stateKey);
562
502
  break;
563
- }
564
503
  }
565
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
+ }
566
510
  function removeFromPendingChanges(set, localId, stateKey) {
567
511
  set((s) => {
568
- s._pendingChanges = (s._pendingChanges || []).filter(
569
- (p) => !(p.localId === localId && p.stateKey === stateKey)
570
- );
571
- return 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
+ };
572
519
  });
573
520
  }
574
521
  function omitSyncFields(item) {
@@ -580,7 +527,13 @@ function nextLocalId() {
580
527
  return crypto.randomUUID();
581
528
  }
582
529
  function newLogger(logger, min) {
583
- const order = { debug: 10, info: 20, warn: 30, error: 40, none: 100 };
530
+ const order = {
531
+ debug: 10,
532
+ info: 20,
533
+ warn: 30,
534
+ error: 40,
535
+ none: 100
536
+ };
584
537
  const threshold = order[min];
585
538
  const enabled = (lvl) => order[lvl] >= threshold;
586
539
  return {
@@ -600,6 +553,7 @@ function findApi(stateKey, syncApi) {
600
553
  export {
601
554
  SyncAction,
602
555
  createIndexedDBStorage,
556
+ createStoreWithSync,
603
557
  nextLocalId,
604
558
  persistWithSync
605
559
  };