@anfenn/zync 0.2.0 → 0.3.0

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
@@ -22,20 +22,23 @@ function newLogger(base, min) {
22
22
  }
23
23
 
24
24
  // src/helpers.ts
25
+ var SYNC_FIELDS = ["_localId", "updated_at", "deleted"];
25
26
  function nextLocalId() {
26
27
  return crypto.randomUUID();
27
28
  }
28
29
  function orderFor(a) {
29
30
  switch (a) {
30
- case "create-or-update" /* CreateOrUpdate */:
31
+ case "create" /* Create */:
31
32
  return 1;
32
- case "remove" /* Remove */:
33
+ case "update" /* Update */:
33
34
  return 2;
35
+ case "remove" /* Remove */:
36
+ return 3;
34
37
  }
35
38
  }
36
- function omitSyncFields(item, fields) {
39
+ function omitSyncFields(item) {
37
40
  const result = { ...item };
38
- for (const k of fields) delete result[k];
41
+ for (const k of SYNC_FIELDS) delete result[k];
39
42
  return result;
40
43
  }
41
44
  function samePendingVersion(get, stateKey, localId, version) {
@@ -54,6 +57,38 @@ function removeFromPendingChanges(set, localId, stateKey) {
54
57
  };
55
58
  });
56
59
  }
60
+ function tryAddToPendingChanges(pendingChanges, stateKey, changes) {
61
+ for (const [localId, change] of changes) {
62
+ let omittedItem = omitSyncFields(change.changes);
63
+ const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
64
+ const hasChanges = Object.keys(omittedItem).length > 0;
65
+ const action = change.updatedItem === null ? "remove" /* Remove */ : change.currentItem === null ? "create" /* Create */ : "update" /* Update */;
66
+ if (action === "update" /* Update */ && change.updatedItem && change.currentItem && change.currentItem._localId !== change.updatedItem._localId) {
67
+ omittedItem = omitSyncFields(change.updatedItem);
68
+ }
69
+ if (queueItem) {
70
+ if (queueItem.action === "remove" /* Remove */) {
71
+ continue;
72
+ }
73
+ queueItem.version += 1;
74
+ if (action === "remove" /* Remove */) {
75
+ queueItem.action = "remove" /* Remove */;
76
+ } else if (hasChanges) {
77
+ queueItem.changes = { ...queueItem.changes, ...omittedItem };
78
+ }
79
+ } else if (action === "remove" /* Remove */ || hasChanges) {
80
+ pendingChanges.push({ action, stateKey, localId, id: change.id, version: 1, changes: omittedItem });
81
+ }
82
+ }
83
+ }
84
+ function setPendingChangeToUpdate(get, stateKey, localId, id) {
85
+ const pendingChanges = get().syncState.pendingChanges || [];
86
+ const change = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localId);
87
+ if (change) {
88
+ change.action = "update" /* Update */;
89
+ if (id) change.id = id;
90
+ }
91
+ }
57
92
  function findApi(stateKey, syncApi) {
58
93
  const api = syncApi[stateKey];
59
94
  if (!api || !api.add || !api.update || !api.remove || !api.list || !api.firstLoad) {
@@ -65,11 +100,12 @@ function findChanges(current, updated) {
65
100
  const currentMap = /* @__PURE__ */ new Map();
66
101
  for (const item of current) {
67
102
  if (item && item._localId) {
68
- currentMap.set(item._localId, item);
103
+ currentMap.set(item._localId, { ...item });
69
104
  }
70
105
  }
71
106
  const changesMap = /* @__PURE__ */ new Map();
72
- for (const item of updated) {
107
+ for (const update of updated) {
108
+ const item = { ...update };
73
109
  if (item && item._localId) {
74
110
  const curr = currentMap.get(item._localId);
75
111
  if (curr) {
@@ -80,16 +116,16 @@ function findChanges(current, updated) {
80
116
  }
81
117
  }
82
118
  if (Object.keys(diff).length > 0) {
83
- changesMap.set(item._localId, { currentItem: curr, updatedItem: item, changes: diff });
119
+ changesMap.set(item._localId, { currentItem: curr, updatedItem: item, changes: diff, id: curr.id ?? item.id });
84
120
  }
85
121
  } else {
86
- changesMap.set(item._localId, { currentItem: null, updatedItem: item, changes: item });
122
+ changesMap.set(item._localId, { currentItem: null, updatedItem: item, changes: item, id: item.id });
87
123
  }
88
124
  }
89
125
  }
90
126
  for (const [localId, curr] of currentMap) {
91
127
  if (!updated.some((u) => u && u._localId === localId)) {
92
- changesMap.set(localId, { currentItem: curr, updatedItem: null, changes: null });
128
+ changesMap.set(localId, { currentItem: curr, updatedItem: null, changes: null, id: curr.id });
93
129
  }
94
130
  }
95
131
  return changesMap;
@@ -125,9 +161,9 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
125
161
  continue;
126
162
  }
127
163
  delete remote.deleted;
128
- const pending = localItem && pendingChanges.some((p) => p.stateKey === stateKey && p.localId === localItem._localId);
129
164
  if (localItem) {
130
- if (pending) {
165
+ const pendingChange = pendingChanges.find((p) => p.stateKey === stateKey && p.localId === localItem._localId);
166
+ if (pendingChange) {
131
167
  switch (conflictResolutionStrategy) {
132
168
  case "local-wins":
133
169
  logger.debug(`[zync] pull:conflict-strategy:${conflictResolutionStrategy} stateKey=${stateKey} id=${remote.id}`);
@@ -158,11 +194,10 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
158
194
  } else {
159
195
  const merged = {
160
196
  ...localItem,
161
- ...remote,
162
- _localId: localItem._localId
197
+ ...remote
163
198
  };
164
199
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
165
- logger.debug(`[zync] pull:merge stateKey=${stateKey} id=${remote.id}`);
200
+ logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
166
201
  }
167
202
  } else {
168
203
  nextItems = [...nextItems, { ...remote, _localId: nextLocalId() }];
@@ -183,96 +218,85 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
183
218
  }
184
219
 
185
220
  // src/push.ts
186
- var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
187
221
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
188
222
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
189
- const { action, stateKey, localId, id, version } = change;
223
+ const { action, stateKey, localId, id, version, changes } = change;
224
+ const changesClone = { ...changes };
190
225
  switch (action) {
191
226
  case "remove" /* Remove */:
192
227
  if (!id) {
193
- logger.warn(`[zync] push:remove:no-id ${stateKey} ${localId}`);
228
+ logger.warn(`[zync] push:remove:no-id stateKey=${stateKey} localId=${localId}`);
194
229
  removeFromPendingChanges(set, localId, stateKey);
195
230
  return;
196
231
  }
197
232
  await api.remove(id);
198
- logger.debug(`[zync] push:remove:success ${stateKey} ${localId} ${id}`);
233
+ logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
199
234
  removeFromPendingChanges(set, localId, stateKey);
200
235
  break;
201
- case "create-or-update" /* CreateOrUpdate */: {
202
- const state = get();
203
- const items = state[stateKey] || [];
204
- const item = items.find((i) => i._localId === localId);
205
- if (!item) {
206
- logger.warn(`[zync] push:create-or-update:no-local-item`, {
207
- stateKey,
208
- localId
209
- });
210
- removeFromPendingChanges(set, localId, stateKey);
236
+ case "update" /* Update */: {
237
+ const changed = await api.update(id, changesClone);
238
+ if (changed) {
239
+ logger.debug(`[zync] push:update:success stateKey=${stateKey} localId=${localId} id=${id}`);
240
+ if (samePendingVersion(get, stateKey, localId, version)) {
241
+ removeFromPendingChanges(set, localId, stateKey);
242
+ }
211
243
  return;
212
- }
213
- const omittedItem = omitSyncFields(item, SYNC_FIELDS);
214
- if (item.id) {
215
- const changed = await api.update(item.id, omittedItem);
216
- if (changed) {
217
- logger.debug("[zync] push:update:success", {
218
- stateKey,
219
- localId,
220
- id: item.id
221
- });
222
- if (samePendingVersion(get, stateKey, localId, version)) {
223
- removeFromPendingChanges(set, localId, stateKey);
224
- }
244
+ } else {
245
+ const state = get();
246
+ const items = state[stateKey] || [];
247
+ const item = items.find((i) => i._localId === localId);
248
+ if (!item) {
249
+ logger.warn(`[zync] push:missing-remote:no-local-item stateKey=${stateKey} localId=${localId}`);
250
+ removeFromPendingChanges(set, localId, stateKey);
225
251
  return;
226
- } else {
227
- switch (missingStrategy) {
228
- case "delete-local-record":
229
- set((s) => ({
230
- [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
231
- }));
232
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
233
- break;
234
- case "insert-remote-record":
235
- omittedItem._localId = crypto.randomUUID();
236
- omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
237
- setAndQueueToSync((s) => ({
238
- [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
239
- }));
240
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
241
- break;
242
- case "ignore":
243
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
244
- break;
245
- default:
246
- logger.error(`[zync] push:missing-remote:unknown stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
247
- break;
252
+ }
253
+ switch (missingStrategy) {
254
+ case "delete-local-record":
255
+ set((s) => ({
256
+ [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
257
+ }));
258
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
259
+ break;
260
+ case "insert-remote-record": {
261
+ const newItem = {
262
+ ...item,
263
+ _localId: nextLocalId(),
264
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
265
+ };
266
+ setAndQueueToSync((s) => ({
267
+ [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? newItem : i)
268
+ }));
269
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${newItem.id}`);
270
+ break;
248
271
  }
249
- removeFromPendingChanges(set, localId, stateKey);
250
- onMissingRemoteRecordDuringUpdate?.(missingStrategy, item, omittedItem._localId);
272
+ case "ignore":
273
+ logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
274
+ break;
275
+ default:
276
+ logger.error(`[zync] push:missing-remote:unknown-strategy stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
277
+ break;
251
278
  }
252
- return;
279
+ removeFromPendingChanges(set, localId, stateKey);
280
+ onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
253
281
  }
254
- const result = await api.add(omittedItem);
282
+ break;
283
+ }
284
+ case "create" /* Create */: {
285
+ const result = await api.add(changesClone);
255
286
  if (result) {
256
- logger.debug("[zync] push:create:success", {
257
- stateKey,
258
- localId,
259
- id: result.id
260
- });
287
+ logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
261
288
  set((s) => ({
262
289
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
263
290
  }));
264
291
  if (samePendingVersion(get, stateKey, localId, version)) {
265
292
  removeFromPendingChanges(set, localId, stateKey);
293
+ } else {
294
+ setPendingChangeToUpdate(get, stateKey, localId, result.id);
266
295
  }
267
- onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
268
- ...item,
269
- ...result
270
- });
296
+ const finalItem = { ...changes, ...result, _localId: localId };
297
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
271
298
  } else {
272
- logger.warn("[zync] push:create:no-result", {
273
- stateKey,
274
- localId
275
- });
299
+ logger.warn(`[zync] push:create:no-result stateKey=${stateKey} localId=${localId} id=${id}`);
276
300
  if (samePendingVersion(get, stateKey, localId, version)) {
277
301
  removeFromPendingChanges(set, localId, stateKey);
278
302
  }
@@ -282,6 +306,74 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
282
306
  }
283
307
  }
284
308
 
309
+ // src/firstLoad.ts
310
+ async function startFirstLoad(set, syncApi, logger) {
311
+ let syncError;
312
+ for (const stateKey of Object.keys(syncApi)) {
313
+ try {
314
+ logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
315
+ const api = findApi(stateKey, syncApi);
316
+ let lastId;
317
+ while (true) {
318
+ const batch = await api.firstLoad(lastId);
319
+ if (!batch?.length) break;
320
+ set((state) => {
321
+ const local = state[stateKey] || [];
322
+ const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
323
+ let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
324
+ const next = [...local];
325
+ for (const remote of batch) {
326
+ const remoteUpdated = new Date(remote.updated_at || 0);
327
+ if (remoteUpdated > newest) newest = remoteUpdated;
328
+ if (remote.deleted) continue;
329
+ delete remote.deleted;
330
+ const localItem = remote.id ? localById.get(remote.id) : void 0;
331
+ if (localItem) {
332
+ const merged = {
333
+ ...localItem,
334
+ ...remote,
335
+ _localId: localItem._localId
336
+ };
337
+ const idx = next.findIndex((i) => i._localId === localItem._localId);
338
+ if (idx >= 0) next[idx] = merged;
339
+ } else {
340
+ next.push({
341
+ ...remote,
342
+ _localId: nextLocalId()
343
+ });
344
+ }
345
+ }
346
+ return {
347
+ [stateKey]: next,
348
+ syncState: {
349
+ ...state.syncState || {},
350
+ lastPulled: {
351
+ ...state.syncState.lastPulled || {},
352
+ [stateKey]: newest.toISOString()
353
+ }
354
+ }
355
+ };
356
+ });
357
+ if (lastId !== void 0 && lastId === batch[batch.length - 1].id) {
358
+ throw new Error(`Duplicate records downloaded, stopping to prevent infinite loop`);
359
+ }
360
+ lastId = batch[batch.length - 1].id;
361
+ }
362
+ logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
363
+ } catch (err) {
364
+ syncError = syncError ?? err;
365
+ logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
366
+ }
367
+ }
368
+ set((state) => ({
369
+ syncState: {
370
+ ...state.syncState || {},
371
+ firstLoadDone: true,
372
+ error: syncError
373
+ }
374
+ }));
375
+ }
376
+
285
377
  // src/indexedDBStorage.ts
286
378
  function createIndexedDBStorage(options) {
287
379
  const dbName = options.dbName;
@@ -362,7 +454,8 @@ function createIndexedDBStorage(options) {
362
454
 
363
455
  // src/index.ts
364
456
  var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
365
- SyncAction2["CreateOrUpdate"] = "create-or-update";
457
+ SyncAction2["Create"] = "create";
458
+ SyncAction2["Update"] = "update";
366
459
  SyncAction2["Remove"] = "remove";
367
460
  return SyncAction2;
368
461
  })(SyncAction || {});
@@ -434,13 +527,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
434
527
  status: "syncing"
435
528
  }
436
529
  }));
437
- let syncError;
530
+ let firstSyncError;
438
531
  for (const stateKey of Object.keys(syncApi)) {
439
532
  try {
440
533
  const api = findApi(stateKey, syncApi);
441
534
  await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
442
535
  } catch (err) {
443
- syncError = syncError ?? err;
536
+ firstSyncError = firstSyncError ?? err;
444
537
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
445
538
  }
446
539
  }
@@ -461,7 +554,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
461
554
  syncOptions.onAfterRemoteAdd
462
555
  );
463
556
  } catch (err) {
464
- syncError = syncError ?? err;
557
+ firstSyncError = firstSyncError ?? err;
465
558
  logger.error(`[zync] push:error change=${change}`, err);
466
559
  }
467
560
  }
@@ -469,76 +562,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
469
562
  syncState: {
470
563
  ...state2.syncState || {},
471
564
  status: "idle",
472
- error: syncError
565
+ error: firstSyncError
473
566
  }
474
567
  }));
475
- if (get().syncState.pendingChanges.length > 0 && !syncError) {
568
+ if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
476
569
  await syncOnce();
477
570
  }
478
571
  }
479
- async function startFirstLoad() {
480
- let syncError;
481
- for (const stateKey of Object.keys(syncApi)) {
482
- try {
483
- logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
484
- const api = findApi(stateKey, syncApi);
485
- let lastId;
486
- while (true) {
487
- const batch = await api.firstLoad(lastId);
488
- if (!batch?.length) break;
489
- set((state) => {
490
- const local = state[stateKey] || [];
491
- const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
492
- let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
493
- const next = [...local];
494
- for (const remote of batch) {
495
- const remoteUpdated = new Date(remote.updated_at || 0);
496
- if (remoteUpdated > newest) newest = remoteUpdated;
497
- if (remote.deleted) continue;
498
- delete remote.deleted;
499
- const localItem = remote.id ? localById.get(remote.id) : void 0;
500
- if (localItem) {
501
- const merged = {
502
- ...localItem,
503
- ...remote,
504
- _localId: localItem._localId
505
- };
506
- const idx = next.findIndex((i) => i._localId === localItem._localId);
507
- if (idx >= 0) next[idx] = merged;
508
- } else {
509
- next.push({
510
- ...remote,
511
- _localId: nextLocalId()
512
- });
513
- }
514
- }
515
- return {
516
- [stateKey]: next,
517
- syncState: {
518
- ...state.syncState || {},
519
- lastPulled: {
520
- ...state.syncState.lastPulled || {},
521
- [stateKey]: newest.toISOString()
522
- }
523
- }
524
- };
525
- });
526
- lastId = batch[batch.length - 1].id;
527
- }
528
- logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
529
- } catch (err) {
530
- syncError = syncError ?? err;
531
- logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
532
- }
533
- }
534
- set((state) => ({
535
- syncState: {
536
- ...state.syncState || {},
537
- firstLoadDone: true,
538
- error: syncError
539
- }
540
- }));
541
- }
542
572
  function setAndSyncOnce(partial) {
543
573
  if (typeof partial === "function") {
544
574
  set((state) => ({ ...partial(state) }));
@@ -561,7 +591,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
561
591
  const current = state[stateKey];
562
592
  const updated = partial[stateKey];
563
593
  const changes = findChanges(current, updated);
564
- addToPendingChanges(pendingChanges, stateKey, changes);
594
+ tryAddToPendingChanges(pendingChanges, stateKey, changes);
565
595
  });
566
596
  return {
567
597
  ...partial,
@@ -571,25 +601,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
571
601
  }
572
602
  };
573
603
  }
574
- function addToPendingChanges(pendingChanges, stateKey, changes) {
575
- for (const [localId, change] of changes) {
576
- const action = change.updatedItem === null ? "remove" /* Remove */ : "create-or-update" /* CreateOrUpdate */;
577
- const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
578
- if (queueItem) {
579
- queueItem.version += 1;
580
- if (queueItem.action === "create-or-update" /* CreateOrUpdate */ && action === "remove" /* Remove */ && change.currentItem.id) {
581
- queueItem.action = "remove" /* Remove */;
582
- queueItem.id = change.currentItem.id;
583
- logger.debug(`[zync] addToPendingChanges:changed-to-remove action=${action} localId=${localId} v=${queueItem.version}`);
584
- } else {
585
- logger.debug(`[zync] addToPendingChanges:re-queued action=${action} localId=${localId} v=${queueItem.version}`);
586
- }
587
- } else {
588
- pendingChanges.push({ action, stateKey, localId, id: change.currentItem?.id, version: 1 });
589
- logger.debug(`[zync] addToPendingChanges:added action=${action} localId=${localId}`);
590
- }
591
- }
592
- }
593
604
  function enable(enabled) {
594
605
  set((state) => ({
595
606
  syncState: {
@@ -626,7 +637,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
626
637
  }
627
638
  storeApi.sync = {
628
639
  enable,
629
- startFirstLoad
640
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger)
630
641
  };
631
642
  const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
632
643
  return {