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