@anfenn/zync 0.2.1 → 0.3.1

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;
@@ -144,13 +180,6 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
144
180
  // case 'try-shallow-merge':
145
181
  // // Try and merge all fields, fail if not possible due to conflicts
146
182
  // // throw new ConflictError('Details...');
147
- // Object.entries(pendingChange.changes || {}).map(([key, value]) => {
148
- // const localValue = localItem[key];
149
- // const remoteValue = remote[key];
150
- // if (localValue !== undefined && localValue !== value) {
151
- // //throw new ConflictError(`Conflict on ${key}: local=${localValue} remote=${value}`);
152
- // }
153
- // });
154
183
  // break;
155
184
  // case 'custom':
156
185
  // // Hook to allow custom userland logic
@@ -165,8 +194,7 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
165
194
  } else {
166
195
  const merged = {
167
196
  ...localItem,
168
- ...remote,
169
- _localId: localItem._localId
197
+ ...remote
170
198
  };
171
199
  nextItems = nextItems.map((i) => i._localId === localItem._localId ? merged : i);
172
200
  logger.debug(`[zync] pull:merge-remote stateKey=${stateKey} id=${remote.id}`);
@@ -190,96 +218,85 @@ async function pull(set, get, stateKey, api, logger, conflictResolutionStrategy)
190
218
  }
191
219
 
192
220
  // src/push.ts
193
- var SYNC_FIELDS = ["id", "_localId", "updated_at", "deleted"];
194
221
  async function pushOne(set, get, change, api, logger, setAndQueueToSync, missingStrategy, onMissingRemoteRecordDuringUpdate, onAfterRemoteAdd) {
195
222
  logger.debug(`[zync] push:attempt action=${change.action} stateKey=${change.stateKey} localId=${change.localId}`);
196
- const { action, stateKey, localId, id, version } = change;
223
+ const { action, stateKey, localId, id, version, changes } = change;
224
+ const changesClone = { ...changes };
197
225
  switch (action) {
198
226
  case "remove" /* Remove */:
199
227
  if (!id) {
200
- logger.warn(`[zync] push:remove:no-id ${stateKey} ${localId}`);
228
+ logger.warn(`[zync] push:remove:no-id stateKey=${stateKey} localId=${localId}`);
201
229
  removeFromPendingChanges(set, localId, stateKey);
202
230
  return;
203
231
  }
204
232
  await api.remove(id);
205
- logger.debug(`[zync] push:remove:success ${stateKey} ${localId} ${id}`);
233
+ logger.debug(`[zync] push:remove:success stateKey=${stateKey} localId=${localId} id=${id}`);
206
234
  removeFromPendingChanges(set, localId, stateKey);
207
235
  break;
208
- case "create-or-update" /* CreateOrUpdate */: {
209
- const state = get();
210
- const items = state[stateKey] || [];
211
- const item = items.find((i) => i._localId === localId);
212
- if (!item) {
213
- logger.warn(`[zync] push:create-or-update:no-local-item`, {
214
- stateKey,
215
- localId
216
- });
217
- 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
+ }
218
243
  return;
219
- }
220
- const omittedItem = omitSyncFields(item, SYNC_FIELDS);
221
- if (item.id) {
222
- const changed = await api.update(item.id, omittedItem);
223
- if (changed) {
224
- logger.debug("[zync] push:update:success", {
225
- stateKey,
226
- localId,
227
- id: item.id
228
- });
229
- if (samePendingVersion(get, stateKey, localId, version)) {
230
- removeFromPendingChanges(set, localId, stateKey);
231
- }
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);
232
251
  return;
233
- } else {
234
- switch (missingStrategy) {
235
- case "delete-local-record":
236
- set((s) => ({
237
- [stateKey]: (s[stateKey] || []).filter((i) => i._localId !== localId)
238
- }));
239
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
240
- break;
241
- case "insert-remote-record":
242
- omittedItem._localId = crypto.randomUUID();
243
- omittedItem.updated_at = (/* @__PURE__ */ new Date()).toISOString();
244
- setAndQueueToSync((s) => ({
245
- [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? omittedItem : i)
246
- }));
247
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
248
- break;
249
- case "ignore":
250
- logger.debug(`[zync] push:missing-remote:${missingStrategy} stateKey=${stateKey} id=${item.id}`);
251
- break;
252
- default:
253
- logger.error(`[zync] push:missing-remote:unknown stateKey=${stateKey} id=${item.id} strategy=${missingStrategy}`);
254
- 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;
255
271
  }
256
- removeFromPendingChanges(set, localId, stateKey);
257
- 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;
258
278
  }
259
- return;
279
+ removeFromPendingChanges(set, localId, stateKey);
280
+ onMissingRemoteRecordDuringUpdate?.(missingStrategy, item);
260
281
  }
261
- const result = await api.add(omittedItem);
282
+ break;
283
+ }
284
+ case "create" /* Create */: {
285
+ const result = await api.add(changesClone);
262
286
  if (result) {
263
- logger.debug("[zync] push:create:success", {
264
- stateKey,
265
- localId,
266
- id: result.id
267
- });
287
+ logger.debug(`[zync] push:create:success stateKey=${stateKey} localId=${localId} id=${id}`);
268
288
  set((s) => ({
269
289
  [stateKey]: (s[stateKey] || []).map((i) => i._localId === localId ? { ...i, ...result } : i)
270
290
  }));
271
291
  if (samePendingVersion(get, stateKey, localId, version)) {
272
292
  removeFromPendingChanges(set, localId, stateKey);
293
+ } else {
294
+ setPendingChangeToUpdate(get, stateKey, localId, result.id);
273
295
  }
274
- onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, {
275
- ...item,
276
- ...result
277
- });
296
+ const finalItem = { ...changes, ...result, _localId: localId };
297
+ onAfterRemoteAdd?.(set, get, setAndQueueToSync, stateKey, finalItem);
278
298
  } else {
279
- logger.warn("[zync] push:create:no-result", {
280
- stateKey,
281
- localId
282
- });
299
+ logger.warn(`[zync] push:create:no-result stateKey=${stateKey} localId=${localId} id=${id}`);
283
300
  if (samePendingVersion(get, stateKey, localId, version)) {
284
301
  removeFromPendingChanges(set, localId, stateKey);
285
302
  }
@@ -289,6 +306,74 @@ async function pushOne(set, get, change, api, logger, setAndQueueToSync, missing
289
306
  }
290
307
  }
291
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
+
292
377
  // src/indexedDBStorage.ts
293
378
  function createIndexedDBStorage(options) {
294
379
  const dbName = options.dbName;
@@ -369,7 +454,8 @@ function createIndexedDBStorage(options) {
369
454
 
370
455
  // src/index.ts
371
456
  var SyncAction = /* @__PURE__ */ ((SyncAction2) => {
372
- SyncAction2["CreateOrUpdate"] = "create-or-update";
457
+ SyncAction2["Create"] = "create";
458
+ SyncAction2["Update"] = "update";
373
459
  SyncAction2["Remove"] = "remove";
374
460
  return SyncAction2;
375
461
  })(SyncAction || {});
@@ -441,13 +527,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
441
527
  status: "syncing"
442
528
  }
443
529
  }));
444
- let syncError;
530
+ let firstSyncError;
445
531
  for (const stateKey of Object.keys(syncApi)) {
446
532
  try {
447
533
  const api = findApi(stateKey, syncApi);
448
534
  await pull(set, get, stateKey, api, logger, conflictResolutionStrategy);
449
535
  } catch (err) {
450
- syncError = syncError ?? err;
536
+ firstSyncError = firstSyncError ?? err;
451
537
  logger.error(`[zync] pull:error stateKey=${stateKey}`, err);
452
538
  }
453
539
  }
@@ -468,7 +554,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
468
554
  syncOptions.onAfterRemoteAdd
469
555
  );
470
556
  } catch (err) {
471
- syncError = syncError ?? err;
557
+ firstSyncError = firstSyncError ?? err;
472
558
  logger.error(`[zync] push:error change=${change}`, err);
473
559
  }
474
560
  }
@@ -476,76 +562,13 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
476
562
  syncState: {
477
563
  ...state2.syncState || {},
478
564
  status: "idle",
479
- error: syncError
565
+ error: firstSyncError
480
566
  }
481
567
  }));
482
- if (get().syncState.pendingChanges.length > 0 && !syncError) {
568
+ if (get().syncState.pendingChanges.length > 0 && !firstSyncError) {
483
569
  await syncOnce();
484
570
  }
485
571
  }
486
- async function startFirstLoad() {
487
- let syncError;
488
- for (const stateKey of Object.keys(syncApi)) {
489
- try {
490
- logger.info(`[zync] firstLoad:start stateKey=${stateKey}`);
491
- const api = findApi(stateKey, syncApi);
492
- let lastId;
493
- while (true) {
494
- const batch = await api.firstLoad(lastId);
495
- if (!batch?.length) break;
496
- set((state) => {
497
- const local = state[stateKey] || [];
498
- const localById = new Map(local.filter((l) => l.id).map((l) => [l.id, l]));
499
- let newest = new Date(state.syncState.lastPulled[stateKey] || 0);
500
- const next = [...local];
501
- for (const remote of batch) {
502
- const remoteUpdated = new Date(remote.updated_at || 0);
503
- if (remoteUpdated > newest) newest = remoteUpdated;
504
- if (remote.deleted) continue;
505
- delete remote.deleted;
506
- const localItem = remote.id ? localById.get(remote.id) : void 0;
507
- if (localItem) {
508
- const merged = {
509
- ...localItem,
510
- ...remote,
511
- _localId: localItem._localId
512
- };
513
- const idx = next.findIndex((i) => i._localId === localItem._localId);
514
- if (idx >= 0) next[idx] = merged;
515
- } else {
516
- next.push({
517
- ...remote,
518
- _localId: nextLocalId()
519
- });
520
- }
521
- }
522
- return {
523
- [stateKey]: next,
524
- syncState: {
525
- ...state.syncState || {},
526
- lastPulled: {
527
- ...state.syncState.lastPulled || {},
528
- [stateKey]: newest.toISOString()
529
- }
530
- }
531
- };
532
- });
533
- lastId = batch[batch.length - 1].id;
534
- }
535
- logger.info(`[zync] firstLoad:done stateKey=${stateKey}`);
536
- } catch (err) {
537
- syncError = syncError ?? err;
538
- logger.error(`[zync] firstLoad:error stateKey=${stateKey}`, err);
539
- }
540
- }
541
- set((state) => ({
542
- syncState: {
543
- ...state.syncState || {},
544
- firstLoadDone: true,
545
- error: syncError
546
- }
547
- }));
548
- }
549
572
  function setAndSyncOnce(partial) {
550
573
  if (typeof partial === "function") {
551
574
  set((state) => ({ ...partial(state) }));
@@ -568,7 +591,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
568
591
  const current = state[stateKey];
569
592
  const updated = partial[stateKey];
570
593
  const changes = findChanges(current, updated);
571
- addToPendingChanges(pendingChanges, stateKey, changes);
594
+ tryAddToPendingChanges(pendingChanges, stateKey, changes);
572
595
  });
573
596
  return {
574
597
  ...partial,
@@ -578,25 +601,6 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
578
601
  }
579
602
  };
580
603
  }
581
- function addToPendingChanges(pendingChanges, stateKey, changes) {
582
- for (const [localId, change] of changes) {
583
- const action = change.updatedItem === null ? "remove" /* Remove */ : "create-or-update" /* CreateOrUpdate */;
584
- const queueItem = pendingChanges.find((p) => p.localId === localId && p.stateKey === stateKey);
585
- if (queueItem) {
586
- queueItem.version += 1;
587
- if (queueItem.action === "create-or-update" /* CreateOrUpdate */ && action === "remove" /* Remove */ && change.currentItem.id) {
588
- queueItem.action = "remove" /* Remove */;
589
- queueItem.id = change.currentItem.id;
590
- logger.debug(`[zync] addToPendingChanges:changed-to-remove action=${action} localId=${localId} v=${queueItem.version}`);
591
- } else {
592
- logger.debug(`[zync] addToPendingChanges:re-queued action=${action} localId=${localId} v=${queueItem.version}`);
593
- }
594
- } else {
595
- pendingChanges.push({ action, stateKey, localId, id: change.currentItem?.id, version: 1, changes: change.changes });
596
- logger.debug(`[zync] addToPendingChanges:added action=${action} localId=${localId}`);
597
- }
598
- }
599
- }
600
604
  function enable(enabled) {
601
605
  set((state) => ({
602
606
  syncState: {
@@ -633,7 +637,7 @@ function persistWithSync(stateCreator, persistOptions, syncApi, syncOptions = {}
633
637
  }
634
638
  storeApi.sync = {
635
639
  enable,
636
- startFirstLoad
640
+ startFirstLoad: () => startFirstLoad(set, syncApi, logger)
637
641
  };
638
642
  const userState = stateCreator(setAndSyncOnce, get, setAndQueueToSync);
639
643
  return {