@anfenn/zync 0.1.8 → 0.1.10

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