@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.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,98 @@ 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
+ 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
- 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) {
184
+ function queueToSync(action, stateKey, ...localIds) {
271
185
  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;
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
- } else {
296
- if (action === "remove" /* Remove */) {
297
- const item = state[stateKey].find((i) => i._localId === localId);
298
- if (item) changes = { id: item.id };
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
- enabled
216
+ ...state.syncState || {},
217
+ pendingChanges: queue
317
218
  }
318
219
  };
319
220
  });
320
221
  syncOnce();
321
222
  }
322
- document.addEventListener("visibilitychange", async () => {
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 starting now app is in foreground");
326
- await startSync?.();
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._lastPulled?.[stateKey] || 0);
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 = { ...localItem, ...remote, _localId: localItem._localId };
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({ ...remote, _localId: nextLocalId() });
294
+ next.push({
295
+ ...remote,
296
+ _localId: nextLocalId()
297
+ });
357
298
  }
358
299
  }
359
- state[stateKey] = next;
360
- state._lastPulled = {
361
- ...state._lastPulled || {},
362
- [stateKey]: newest.toISOString()
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
- return {
376
- syncState: { ...state.syncState, firstLoadDone: true },
377
- syncError
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
- 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 ?? {}
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 "create" /* Create */:
352
+ case "createOrUpdate" /* CreateOrUpdate */:
402
353
  return 1;
403
- case "update" /* Update */:
404
- return 2;
405
354
  case "remove" /* Remove */:
406
- return 3;
355
+ return 2;
407
356
  }
408
357
  }
409
358
  async function pull(set, get, stateKey, api, logger) {
410
- const lastPulled = get()._lastPulled || {};
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 _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
- );
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 (pendingRemovals.has(remote.id)) {
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
- state[stateKey] = state[stateKey].filter((i) => i.id !== remote.id);
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 = _pendingChanges.some(
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 = { ...localItem, ...remote, _localId: localItem._localId };
445
- state[stateKey] = state[stateKey].map((i) => i._localId === localItem._localId ? merged : i);
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
- state[stateKey] = [...state[stateKey], { ...remote, _localId: nextLocalId() }];
402
+ nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
449
403
  logger.debug(`[persistWithSync] pull:add stateKey=${stateKey} id=${remote.id}`);
450
404
  }
451
405
  }
452
- state._lastPulled = {
453
- ...state._lastPulled,
454
- [stateKey]: newest.toISOString()
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
- `[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);
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 "create" /* Create */: {
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
- if (changes && Object.keys(changes).length) {
475
- await api.update(item.id, omitSyncFields({ ...item, ...changes }));
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 payload = changes ? { ...item, ...changes } : item;
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", { 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;
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", { 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;
497
+ logger.warn("[persistWithSync] push:create:no-result", {
498
+ stateKey,
499
+ localId
508
500
  });
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
- }
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
- s._pendingChanges = (s._pendingChanges || []).filter(
569
- (p) => !(p.localId === localId && p.stateKey === stateKey)
570
- );
571
- return s;
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 = { debug: 10, info: 20, warn: 30, error: 40, none: 100 };
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
  };