cry-synced-db-client 0.1.2 → 0.1.4

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.
@@ -1,6 +1,5 @@
1
1
  import type { Timestamp, AggregateOptions } from "mongodb";
2
- import type { Id } from "../types/DbEntity";
3
- import type { I_RestInterface, QuerySpec, QueryOpts, GetNewerSpec, UpdateSpec, InsertSpec, BatchSpec } from "../types/I_RestInterface";
2
+ import type { I_RestInterface, QuerySpec, QueryOpts, GetNewerSpec, BatchSpec, CollectionUpdateRequest, CollectionUpdateResult } from "../types/I_RestInterface";
4
3
  /** Progress callback for tracking upload progress */
5
4
  export type ProgressCallback = (sentBytes: number, totalBytes: number) => void;
6
5
  export interface RestProxyConfig {
@@ -99,11 +98,8 @@ export declare class RestProxy implements I_RestInterface {
99
98
  ping(): Promise<boolean>;
100
99
  findNewer<T>(collection: string, timestamp: Timestamp | number | string | Date, query?: QuerySpec<T>, opts?: QueryOpts): Promise<T[]>;
101
100
  findNewerMany<T>(spec?: GetNewerSpec<T>[]): Promise<Record<string, any[]>>;
102
- latestTimestamp(collection: string): Promise<Timestamp | undefined>;
103
- latestTimestamps(collections: string[]): Promise<Record<string, Timestamp>>;
104
- save<T>(collection: string, update: UpdateSpec<T>, id?: Id): Promise<T>;
105
- insert<T>(collection: string, insert: InsertSpec<T>): Promise<T>;
106
101
  deleteOne<T>(collection: string, query: QuerySpec<T>): Promise<T>;
107
102
  aggregate<T>(collection: string, pipeline: object[], opts?: AggregateOptions): Promise<T[]>;
108
103
  upsertBatch<T>(collection: string, batch: BatchSpec<T>): Promise<T[]>;
104
+ updateCollections<T>(collectionsBatches: CollectionUpdateRequest<T>[]): Promise<CollectionUpdateResult[]>;
109
105
  }
@@ -14,9 +14,15 @@ export declare class SyncedDb implements I_SyncedDb {
14
14
  private serverUpdateNotifier?;
15
15
  private restTimeoutMs;
16
16
  private syncTimeoutMs;
17
- private debounceMs;
17
+ private debounceDexieWritesMs;
18
+ private debounceRestWritesMs;
18
19
  private onForcedOffline?;
19
20
  private onSync?;
21
+ private onServerWriteRequest?;
22
+ private onServerWriteResult?;
23
+ private onFindNewerManyCall?;
24
+ private onFindNewerCall?;
25
+ private autoSyncIntervalMs?;
20
26
  private online;
21
27
  private syncing;
22
28
  private syncLock;
@@ -24,6 +30,12 @@ export declare class SyncedDb implements I_SyncedDb {
24
30
  private pendingChanges;
25
31
  private unsubscribeServerUpdates?;
26
32
  private beforeUnloadHandler?;
33
+ /** Timer za debounced REST upload */
34
+ private restUploadTimer?;
35
+ /** Flag za preprečitev loopa - če je true, ne schedulamo REST uploada */
36
+ private isUploadingToRest;
37
+ /** Timer za avtomatsko sinhronizacijo */
38
+ private autoSyncTimer?;
27
39
  /** Unikatni ID te instance za detekcijo loopback */
28
40
  private readonly updaterId;
29
41
  constructor(config: SyncedDbConfig);
@@ -36,6 +48,15 @@ export declare class SyncedDb implements I_SyncedDb {
36
48
  */
37
49
  private goOffline;
38
50
  private tryGoOnline;
51
+ /**
52
+ * Zažene avtomatsko periodično sinhronizacijo če je autoSyncIntervalMs konfiguriran.
53
+ * Safe to call multiple times - bo najprej ustavil obstoječi timer.
54
+ */
55
+ private startAutoSync;
56
+ /**
57
+ * Ustavi avtomatsko periodično sinhronizacijo.
58
+ */
59
+ private stopAutoSync;
39
60
  /** Ovije promise s timeout za REST operacije */
40
61
  private withRestTimeout;
41
62
  /** Ovije promise s timeout za sync operacije (daljši timeout) */
@@ -53,27 +74,49 @@ export declare class SyncedDb implements I_SyncedDb {
53
74
  hardDeleteOne<T extends DbEntity>(collection: string, id: Id): Promise<T | null>;
54
75
  hardDelete<T extends DbEntity>(collection: string, query: QuerySpec<T>): Promise<number>;
55
76
  ping(timeoutMs?: number): Promise<boolean>;
56
- sync(): Promise<void>;
77
+ sync(calledFrom?: string): Promise<void>;
57
78
  isSyncing(): boolean;
79
+ /**
80
+ * Batch upsert - server only, does NOT update local state.
81
+ *
82
+ * This is intentional. If you need local state updated after calling this method,
83
+ * call sync() afterward. See I_SyncedDb interface for full documentation.
84
+ */
58
85
  upsertBatch<T extends DbEntity>(collection: string, batch: BatchSpec<T>): Promise<T[]>;
59
86
  getMemoryCollection<T extends DbEntity>(collection: string): T[];
60
- getDebounceMs(): number;
87
+ getDebounceDexieWritesMs(): number;
88
+ getDebounceRestWritesMs(): number;
89
+ getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
61
90
  private assertCollection;
62
91
  private stripLocalFields;
63
92
  private getPendingKey;
64
93
  private schedulePendingChange;
65
94
  private executePendingChange;
95
+ /**
96
+ * Schedulira debounced REST upload dirty objektov
97
+ * Ne naredi nič če smo offline, že uploadamo, ali je sync v teku
98
+ */
99
+ private scheduleRestUpload;
100
+ /**
101
+ * Izvede REST upload dirty objektov
102
+ */
103
+ private executeRestUpload;
66
104
  private flushAllPendingChanges;
67
105
  private recoverPendingWrites;
68
106
  /**
69
107
  * Sinhronizira eno samo kolekcijo - uporabi se pri handleServerUpdate za updateMany/deleteMany
108
+ * Samo obdela prihajajoče podatke, ne pošilja dirty objektov nazaj
70
109
  */
71
110
  private syncSingleCollection;
72
111
  /**
73
- * Sinhronizira kolekcijo s podatki, ki so že bili pridobljeni s serverja
74
- * Vrne statistiko: { conflictsResolved, sentCount }
112
+ * Obdela prihajajoče podatke s serverja - razreši konflikte in shrani v Dexie/inMem
113
+ * Ne pošilja dirty objektov na server (to naredi uploadDirtyItems)
114
+ */
115
+ private processIncomingServerData;
116
+ /**
117
+ * Zbere vse dirty objekte iz vseh kolekcij in jih pošlje na server z enim updateCollections klicem
75
118
  */
76
- private syncCollectionWithData;
119
+ private uploadDirtyItems;
77
120
  private resolveCollectionConflict;
78
121
  private compareTimestamps;
79
122
  private handleServerUpdate;
package/dist/index.js CHANGED
@@ -251,7 +251,8 @@ function getPendingWrites(tenant) {
251
251
  }
252
252
 
253
253
  // src/db/SyncedDb.ts
254
- var DEFAULT_DEBOUNCE_MS = 1000;
254
+ var DEFAULT_DEXIE_DEBOUNCE_MS = 1000;
255
+ var DEFAULT_REST_DEBOUNCE_MS = 2000;
255
256
  var DEFAULT_REST_TIMEOUT_MS = 1e4;
256
257
  var DEFAULT_SYNC_TIMEOUT_MS = 30000;
257
258
  var MAX_RETRY_COUNT = 3;
@@ -265,9 +266,15 @@ class SyncedDb {
265
266
  serverUpdateNotifier;
266
267
  restTimeoutMs;
267
268
  syncTimeoutMs;
268
- debounceMs;
269
+ debounceDexieWritesMs;
270
+ debounceRestWritesMs;
269
271
  onForcedOffline;
270
272
  onSync;
273
+ onServerWriteRequest;
274
+ onServerWriteResult;
275
+ onFindNewerManyCall;
276
+ onFindNewerCall;
277
+ autoSyncIntervalMs;
271
278
  online = false;
272
279
  syncing = false;
273
280
  syncLock = false;
@@ -275,6 +282,9 @@ class SyncedDb {
275
282
  pendingChanges = new Map;
276
283
  unsubscribeServerUpdates;
277
284
  beforeUnloadHandler;
285
+ restUploadTimer;
286
+ isUploadingToRest = false;
287
+ autoSyncTimer;
278
288
  updaterId;
279
289
  constructor(config) {
280
290
  this.tenant = config.tenant;
@@ -284,9 +294,15 @@ class SyncedDb {
284
294
  this.serverUpdateNotifier = config.serverUpdateNotifier;
285
295
  this.restTimeoutMs = config.restTimeoutMs ?? DEFAULT_REST_TIMEOUT_MS;
286
296
  this.syncTimeoutMs = config.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS;
287
- this.debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
297
+ this.debounceDexieWritesMs = config.debounceDexieWritesMs ?? DEFAULT_DEXIE_DEBOUNCE_MS;
298
+ this.debounceRestWritesMs = config.debounceRestWritesMs ?? DEFAULT_REST_DEBOUNCE_MS;
288
299
  this.onForcedOffline = config.onForcedOffline;
289
300
  this.onSync = config.onSync;
301
+ this.onServerWriteRequest = config.onServerWriteRequest;
302
+ this.onServerWriteResult = config.onServerWriteResult;
303
+ this.onFindNewerManyCall = config.onFindNewerManyCall;
304
+ this.onFindNewerCall = config.onFindNewerCall;
305
+ this.autoSyncIntervalMs = config.autoSyncIntervalMs;
290
306
  this.updaterId = Math.random().toString(36).substring(2, 15);
291
307
  for (const col of config.collections) {
292
308
  this.collections.set(col.name, col);
@@ -315,6 +331,11 @@ class SyncedDb {
315
331
  this.initialized = true;
316
332
  }
317
333
  async close() {
334
+ if (this.restUploadTimer) {
335
+ clearTimeout(this.restUploadTimer);
336
+ this.restUploadTimer = undefined;
337
+ }
338
+ this.stopAutoSync();
318
339
  await this.flushAllPendingChanges();
319
340
  if (this.unsubscribeServerUpdates) {
320
341
  this.unsubscribeServerUpdates();
@@ -337,10 +358,14 @@ class SyncedDb {
337
358
  });
338
359
  } else {
339
360
  this.online = online;
361
+ if (!online) {
362
+ this.stopAutoSync();
363
+ }
340
364
  }
341
365
  }
342
366
  goOffline(reason) {
343
367
  this.online = false;
368
+ this.stopAutoSync();
344
369
  if (this.onForcedOffline) {
345
370
  try {
346
371
  this.onForcedOffline(reason);
@@ -357,12 +382,31 @@ class SyncedDb {
357
382
  return;
358
383
  }
359
384
  this.online = true;
360
- await this.sync();
385
+ this.startAutoSync();
386
+ await this.sync("INITIAL SYNC");
361
387
  } catch (err) {
362
388
  console.warn("Failed to go online (ping failed or timed out):", err);
363
389
  this.online = false;
364
390
  }
365
391
  }
392
+ startAutoSync() {
393
+ this.stopAutoSync();
394
+ if (!this.autoSyncIntervalMs || this.autoSyncIntervalMs <= 0) {
395
+ return;
396
+ }
397
+ const intervalMs = this.autoSyncIntervalMs;
398
+ this.autoSyncTimer = setInterval(() => {
399
+ this.sync(`interval ${intervalMs}ms`).catch((err) => {
400
+ console.error("Auto-sync failed:", err);
401
+ });
402
+ }, intervalMs);
403
+ }
404
+ stopAutoSync() {
405
+ if (this.autoSyncTimer) {
406
+ clearInterval(this.autoSyncTimer);
407
+ this.autoSyncTimer = undefined;
408
+ }
409
+ }
366
410
  withRestTimeout(promise, operation) {
367
411
  return new Promise((resolve, reject) => {
368
412
  const timer = setTimeout(() => {
@@ -595,7 +639,7 @@ class SyncedDb {
595
639
  return false;
596
640
  }
597
641
  }
598
- async sync() {
642
+ async sync(calledFrom) {
599
643
  if (!this.online || this.syncLock)
600
644
  return;
601
645
  this.syncLock = true;
@@ -618,14 +662,26 @@ class SyncedDb {
618
662
  });
619
663
  configMap.set(collectionName, config);
620
664
  }
665
+ if (this.onFindNewerManyCall) {
666
+ try {
667
+ this.onFindNewerManyCall({
668
+ specs: syncSpecs,
669
+ timestamp: new Date,
670
+ calledFrom
671
+ });
672
+ } catch (err) {
673
+ console.error("onFindNewerManyCall callback failed:", err);
674
+ }
675
+ }
621
676
  const allServerData = await this.withSyncTimeout(this.restInterface.findNewerMany(syncSpecs), "findNewerMany");
622
677
  for (const [collectionName, config] of this.collections) {
623
678
  const serverData = allServerData[collectionName] || [];
624
679
  receivedCount += serverData.length;
625
- const stats = await this.syncCollectionWithData(collectionName, config, serverData);
626
- sentCount += stats.sentCount;
680
+ const stats = await this.processIncomingServerData(collectionName, config, serverData);
627
681
  conflictsResolved += stats.conflictsResolved;
628
682
  }
683
+ const uploadStats = await this.uploadDirtyItems(calledFrom);
684
+ sentCount = uploadStats.sentCount;
629
685
  if (this.onSync) {
630
686
  try {
631
687
  this.onSync({
@@ -633,7 +689,8 @@ class SyncedDb {
633
689
  receivedCount,
634
690
  sentCount,
635
691
  conflictsResolved,
636
- success: true
692
+ success: true,
693
+ calledFrom
637
694
  });
638
695
  } catch (err) {
639
696
  console.error("onSync callback failed:", err);
@@ -651,7 +708,8 @@ class SyncedDb {
651
708
  sentCount,
652
709
  conflictsResolved,
653
710
  success: false,
654
- error: err instanceof Error ? err : new Error(String(err))
711
+ error: err instanceof Error ? err : new Error(String(err)),
712
+ calledFrom
655
713
  });
656
714
  } catch (callbackErr) {
657
715
  console.error("onSync callback failed:", callbackErr);
@@ -677,8 +735,19 @@ class SyncedDb {
677
735
  this.assertCollection(collection);
678
736
  return this.inMemDb.getAll(collection);
679
737
  }
680
- getDebounceMs() {
681
- return this.debounceMs;
738
+ getDebounceDexieWritesMs() {
739
+ return this.debounceDexieWritesMs;
740
+ }
741
+ getDebounceRestWritesMs() {
742
+ return this.debounceRestWritesMs;
743
+ }
744
+ async getDirty() {
745
+ const result = {};
746
+ for (const [collectionName] of this.collections) {
747
+ const dirtyItems = await this.dexieDb.getDirty(collectionName);
748
+ result[collectionName] = dirtyItems.map((item) => this.stripLocalFields(item));
749
+ }
750
+ return result;
682
751
  }
683
752
  assertCollection(name) {
684
753
  if (!this.collections.has(name)) {
@@ -702,7 +771,7 @@ class SyncedDb {
702
771
  savePendingWrite(this.tenant, collection, id, fullData);
703
772
  const timer = setTimeout(() => {
704
773
  this.executePendingChange(key);
705
- }, this.debounceMs);
774
+ }, this.debounceDexieWritesMs);
706
775
  const newRetryCount = retryCount > 0 ? retryCount : existing?.retryCount ?? 0;
707
776
  this.pendingChanges.set(key, {
708
777
  collection,
@@ -728,6 +797,7 @@ class SyncedDb {
728
797
  });
729
798
  }
730
799
  clearPendingWrite(this.tenant, pending.collection, pending.id);
800
+ this.scheduleRestUpload();
731
801
  } catch (err) {
732
802
  console.error("Failed to write to Dexie:", err);
733
803
  const newRetryCount = pending.retryCount + 1;
@@ -738,6 +808,30 @@ class SyncedDb {
738
808
  this.schedulePendingChange(pending.collection, pending.id, pending.data, newRetryCount);
739
809
  }
740
810
  }
811
+ scheduleRestUpload() {
812
+ if (!this.online || this.isUploadingToRest || this.syncing) {
813
+ return;
814
+ }
815
+ if (this.restUploadTimer) {
816
+ clearTimeout(this.restUploadTimer);
817
+ }
818
+ this.restUploadTimer = setTimeout(() => {
819
+ this.executeRestUpload();
820
+ }, this.debounceRestWritesMs);
821
+ }
822
+ async executeRestUpload() {
823
+ if (this.isUploadingToRest || this.syncing || !this.online) {
824
+ return;
825
+ }
826
+ this.isUploadingToRest = true;
827
+ try {
828
+ await this.uploadDirtyItems();
829
+ } catch (err) {
830
+ console.error("REST upload failed:", err);
831
+ } finally {
832
+ this.isUploadingToRest = false;
833
+ }
834
+ }
741
835
  async flushAllPendingChanges() {
742
836
  const promises = [];
743
837
  for (const [key, pending] of this.pendingChanges) {
@@ -767,10 +861,23 @@ class SyncedDb {
767
861
  if (!config)
768
862
  return;
769
863
  const meta = await this.dexieDb.getSyncMeta(collectionName);
770
- const serverData = await this.restInterface.findNewer(collectionName, meta?.lastSyncTs || 0, config.query, { returnDeleted: true });
771
- await this.syncCollectionWithData(collectionName, config, serverData);
864
+ const fromTimestamp = meta?.lastSyncTs || 0;
865
+ if (this.onFindNewerCall) {
866
+ try {
867
+ this.onFindNewerCall({
868
+ collection: collectionName,
869
+ fromTimestamp,
870
+ query: config.query,
871
+ timestamp: new Date
872
+ });
873
+ } catch (err) {
874
+ console.error("onFindNewerCall callback failed:", err);
875
+ }
876
+ }
877
+ const serverData = await this.restInterface.findNewer(collectionName, fromTimestamp, config.query, { returnDeleted: true });
878
+ await this.processIncomingServerData(collectionName, config, serverData);
772
879
  }
773
- async syncCollectionWithData(collectionName, config, serverData) {
880
+ async processIncomingServerData(collectionName, config, serverData) {
774
881
  let maxTs;
775
882
  let conflictsResolved = 0;
776
883
  const dexieBatch = [];
@@ -827,41 +934,107 @@ class SyncedDb {
827
934
  for (const id of inMemDeleteIds) {
828
935
  this.inMemDb.deleteOne(collectionName, id);
829
936
  }
830
- const dirtyItems = await this.dexieDb.getDirty(collectionName);
831
- const deletedItems = dirtyItems.filter((item) => item._deleted);
832
- const saveItems = dirtyItems.filter((item) => !item._deleted);
833
- let sentCount = 0;
834
- for (const item of deletedItems) {
835
- try {
836
- await this.restInterface.deleteOne(collectionName, { _id: item._id });
837
- await this.dexieDb.deleteOne(collectionName, item._id);
838
- sentCount++;
839
- } catch (err) {
840
- console.error(`Failed to sync deleted item ${String(item._id)}:`, err);
937
+ if (maxTs) {
938
+ await this.dexieDb.setSyncMeta(collectionName, maxTs);
939
+ }
940
+ return { conflictsResolved, maxTs };
941
+ }
942
+ async uploadDirtyItems(calledFrom) {
943
+ const collectionBatches = [];
944
+ const dirtyItemsMap = new Map;
945
+ for (const [collectionName] of this.collections) {
946
+ const dirtyItems = await this.dexieDb.getDirty(collectionName);
947
+ if (dirtyItems.length === 0)
948
+ continue;
949
+ const updates = [];
950
+ const deletes = [];
951
+ for (const item of dirtyItems) {
952
+ if (item._deleted) {
953
+ deletes.push(item);
954
+ } else {
955
+ updates.push(item);
956
+ }
841
957
  }
958
+ dirtyItemsMap.set(collectionName, { updates, deletes });
959
+ collectionBatches.push([{
960
+ collection: collectionName,
961
+ batch: {
962
+ updates: updates.map((item) => ({
963
+ _id: item._id,
964
+ update: this.stripLocalFields(item)
965
+ })),
966
+ deletes: deletes.map((item) => ({ _id: item._id }))
967
+ }
968
+ }]);
969
+ }
970
+ if (collectionBatches.length === 0) {
971
+ return { sentCount: 0 };
842
972
  }
843
- for (const item of saveItems) {
973
+ if (this.onServerWriteRequest) {
844
974
  try {
845
- const saved = await this.restInterface.save(collectionName, this.stripLocalFields(item), item._id);
846
- await this.dexieDb.save(collectionName, item._id, {
847
- ...saved,
848
- _dirty: false
975
+ this.onServerWriteRequest({
976
+ batches: collectionBatches,
977
+ timestamp: new Date,
978
+ calledFrom
849
979
  });
850
- this.inMemDb.save(collectionName, item._id, this.stripLocalFields(saved));
851
- if (saved._ts) {
852
- if (!maxTs || this.compareTimestamps(saved._ts, maxTs) > 0) {
853
- maxTs = saved._ts;
854
- }
855
- }
856
- sentCount++;
857
980
  } catch (err) {
858
- console.error(`Failed to sync item ${String(item._id)}:`, err);
981
+ console.error("onServerWriteRequest callback failed:", err);
859
982
  }
860
983
  }
861
- if (maxTs) {
862
- await this.dexieDb.setSyncMeta(collectionName, maxTs);
984
+ const writeStartTime = Date.now();
985
+ let results;
986
+ try {
987
+ results = await this.withSyncTimeout(this.restInterface.updateCollections(collectionBatches), "updateCollections");
988
+ if (this.onServerWriteResult) {
989
+ try {
990
+ this.onServerWriteResult({
991
+ results,
992
+ durationMs: Date.now() - writeStartTime,
993
+ success: true,
994
+ calledFrom
995
+ });
996
+ } catch (err) {
997
+ console.error("onServerWriteResult callback failed:", err);
998
+ }
999
+ }
1000
+ } catch (err) {
1001
+ if (this.onServerWriteResult) {
1002
+ try {
1003
+ this.onServerWriteResult({
1004
+ results: [],
1005
+ durationMs: Date.now() - writeStartTime,
1006
+ success: false,
1007
+ error: err instanceof Error ? err : new Error(String(err)),
1008
+ calledFrom
1009
+ });
1010
+ } catch (callbackErr) {
1011
+ console.error("onServerWriteResult callback failed:", callbackErr);
1012
+ }
1013
+ }
1014
+ throw err;
863
1015
  }
864
- return { conflictsResolved, sentCount };
1016
+ let sentCount = 0;
1017
+ for (const result of results) {
1018
+ const { collection, results: { updatedIds, deletedIds, errors } } = result;
1019
+ const dirtyData = dirtyItemsMap.get(collection);
1020
+ if (!dirtyData)
1021
+ continue;
1022
+ for (const id of updatedIds) {
1023
+ const item = dirtyData.updates.find((u) => String(u._id) === String(id));
1024
+ if (item) {
1025
+ await this.dexieDb.save(collection, id, { _dirty: false });
1026
+ sentCount++;
1027
+ }
1028
+ }
1029
+ for (const id of deletedIds) {
1030
+ await this.dexieDb.deleteOne(collection, id);
1031
+ sentCount++;
1032
+ }
1033
+ if (errors) {
1034
+ console.error(`Sync errors for ${collection}:`, errors);
1035
+ }
1036
+ }
1037
+ return { sentCount };
865
1038
  }
866
1039
  resolveCollectionConflict(collectionName, config, local, external) {
867
1040
  if (config.resolveSyncConflict) {
@@ -885,6 +1058,18 @@ class SyncedDb {
885
1058
  switch (payload.operation) {
886
1059
  case "insert":
887
1060
  case "update": {
1061
+ if (this.onFindNewerCall) {
1062
+ try {
1063
+ this.onFindNewerCall({
1064
+ collection: collectionName,
1065
+ fromTimestamp: 0,
1066
+ query: { _id: payload._id },
1067
+ timestamp: new Date
1068
+ });
1069
+ } catch (err) {
1070
+ console.error("onFindNewerCall callback failed:", err);
1071
+ }
1072
+ }
888
1073
  const items = await this.restInterface.findNewer(collectionName, 0, { _id: payload._id });
889
1074
  const serverItem = items[0];
890
1075
  if (serverItem) {
@@ -902,22 +1087,7 @@ class SyncedDb {
902
1087
  break;
903
1088
  }
904
1089
  case "batch": {
905
- const deleteIds = [];
906
- const updateIds = [];
907
- for (const item of payload.data) {
908
- if (item.operation === "delete") {
909
- deleteIds.push(item._id);
910
- } else {
911
- updateIds.push(item._id);
912
- }
913
- }
914
- if (deleteIds.length > 0) {
915
- await Promise.all(deleteIds.map((id) => this.handleServerItemDelete(collectionName, id)));
916
- }
917
- if (updateIds.length > 0) {
918
- const serverItems = await Promise.all(updateIds.map((id) => this.restInterface.findNewer(collectionName, 0, { _id: id }).then((items) => items[0])));
919
- await Promise.all(serverItems.filter((item) => !!item).map((serverItem) => this.handleServerItemUpdate(collectionName, serverItem)));
920
- }
1090
+ await this.syncSingleCollection(collectionName);
921
1091
  break;
922
1092
  }
923
1093
  }
@@ -1298,26 +1468,6 @@ class RestProxy {
1298
1468
  async findNewerMany(spec) {
1299
1469
  return await this.restCall("findNewerMany", { spec });
1300
1470
  }
1301
- async latestTimestamp(collection) {
1302
- return await this.restCall("latestTimestamp", {
1303
- collection
1304
- });
1305
- }
1306
- async latestTimestamps(collections) {
1307
- return await this.restCall("latestTimestamps", {
1308
- collections
1309
- });
1310
- }
1311
- async save(collection, update, id) {
1312
- return await this.restCall("save", {
1313
- collection,
1314
- update,
1315
- id: id ? String(id) : undefined
1316
- });
1317
- }
1318
- async insert(collection, insert) {
1319
- return await this.restCall("insert", { collection, insert });
1320
- }
1321
1471
  async deleteOne(collection, query) {
1322
1472
  return await this.restCall("deleteOne", { collection, query });
1323
1473
  }
@@ -1327,6 +1477,11 @@ class RestProxy {
1327
1477
  async upsertBatch(collection, batch) {
1328
1478
  return await this.restCall("upsertBatch", { collection, batch });
1329
1479
  }
1480
+ async updateCollections(collectionsBatches) {
1481
+ return await this.restCall("updateCollections", {
1482
+ batch: collectionsBatches
1483
+ });
1484
+ }
1330
1485
  }
1331
1486
  export {
1332
1487
  resolveConflict,
@@ -33,6 +33,38 @@ export interface GetNewerSpec<T> {
33
33
  query?: QuerySpec<T>;
34
34
  opts?: QueryOpts;
35
35
  }
36
+ /**
37
+ * Request to batch update a collection, used to sync data
38
+ */
39
+ export type CollectionUpdateRequest<T> = {
40
+ collection: string;
41
+ batch: {
42
+ updates: {
43
+ _id: Id;
44
+ update: Partial<T>;
45
+ }[];
46
+ deletes: {
47
+ _id: Id;
48
+ }[];
49
+ };
50
+ }[];
51
+ /**
52
+ * Result of updateCollections()
53
+ */
54
+ export type CollectionUpdateResult = {
55
+ collection: string;
56
+ results: {
57
+ /** IDs of records actually updated */
58
+ updatedIds: Id[];
59
+ /** IDs of records actually deleted */
60
+ deletedIds: Id[];
61
+ /** errors returned by object */
62
+ errors?: {
63
+ _id: string;
64
+ error: string;
65
+ };
66
+ };
67
+ };
36
68
  /**
37
69
  * Minimalni interface za komunikacijo s serverjem (cry-db Mongo)
38
70
  * Vsebuje samo metode, potrebne za sinhronizacijo
@@ -42,13 +74,11 @@ export interface I_RestInterface {
42
74
  ping(): Promise<boolean>;
43
75
  findNewer<T>(collection: string, timestamp: Timestamp | number | string | Date, query?: QuerySpec<T>, opts?: QueryOpts): Promise<T[]>;
44
76
  findNewerMany<T>(spec?: GetNewerSpec<T>[]): Promise<Record<string, any[]>>;
45
- latestTimestamp(collection: string): Promise<Timestamp | undefined>;
46
- latestTimestamps(collections: string[]): Promise<Record<string, Timestamp>>;
47
- save<T>(collection: string, update: UpdateSpec<T>, id?: Id): Promise<T>;
48
- insert<T>(collection: string, insert: InsertSpec<T>): Promise<T>;
49
77
  deleteOne<T>(collection: string, query: QuerySpec<T>): Promise<T>;
50
78
  /** Izvede agregacijo na serverju */
51
79
  aggregate<T>(collection: string, pipeline: object[], opts?: AggregateOptions): Promise<T[]>;
52
80
  /** Izvede batch upsert na serverju - ne posodablja lokalne baze */
53
81
  upsertBatch<T>(collection: string, batch: BatchSpec<T>): Promise<T[]>;
82
+ /** Pošlje batch update/delete za več kolekcij naenkrat */
83
+ updateCollections<T>(collectionsBatches: CollectionUpdateRequest<T>[]): Promise<CollectionUpdateResult[]>;
54
84
  }
@@ -1,9 +1,59 @@
1
- import type { AggregateOptions } from "mongodb";
1
+ import type { AggregateOptions, Timestamp } from "mongodb";
2
2
  import type { Id, DbEntity } from "./DbEntity";
3
- import type { QuerySpec, UpdateSpec, InsertSpec, BatchSpec, I_RestInterface } from "./I_RestInterface";
3
+ import type { QuerySpec, UpdateSpec, InsertSpec, BatchSpec, I_RestInterface, CollectionUpdateRequest, CollectionUpdateResult, GetNewerSpec } from "./I_RestInterface";
4
4
  import type { I_DexieDb } from "./I_DexieDb";
5
5
  import type { I_InMemDb } from "./I_InMemDb";
6
6
  import type { I_ServerUpdateNotifier } from "./I_ServerUpdateNotifier";
7
+ /**
8
+ * Callback payload for server write requests (before sending)
9
+ */
10
+ export interface ServerWriteRequestInfo {
11
+ /** Collections and batches being sent */
12
+ batches: CollectionUpdateRequest<any>[];
13
+ /** Timestamp when request started */
14
+ timestamp: Date;
15
+ /** Where sync was called from (for debugging) */
16
+ calledFrom?: string;
17
+ }
18
+ /**
19
+ * Callback payload for server write results (after receiving)
20
+ */
21
+ export interface ServerWriteResultInfo {
22
+ /** Results from server */
23
+ results: CollectionUpdateResult[];
24
+ /** Duration in ms */
25
+ durationMs: number;
26
+ /** Whether the request succeeded */
27
+ success: boolean;
28
+ /** Error if failed */
29
+ error?: Error;
30
+ /** Where sync was called from (for debugging) */
31
+ calledFrom?: string;
32
+ }
33
+ /**
34
+ * Callback payload for findNewerMany calls
35
+ */
36
+ export interface FindNewerManyCallInfo {
37
+ /** Specs being requested */
38
+ specs: GetNewerSpec<any>[];
39
+ /** Timestamp when request started */
40
+ timestamp: Date;
41
+ /** Where sync was called from (for debugging) */
42
+ calledFrom?: string;
43
+ }
44
+ /**
45
+ * Callback payload for findNewer calls
46
+ */
47
+ export interface FindNewerCallInfo {
48
+ /** Collection being queried */
49
+ collection: string;
50
+ /** Timestamp from which to fetch */
51
+ fromTimestamp: Timestamp | number | string | Date;
52
+ /** Query filter if any */
53
+ query?: QuerySpec<any>;
54
+ /** Timestamp when request started */
55
+ timestamp: Date;
56
+ }
7
57
  /**
8
58
  * Informacije o sinhronizaciji za debugging/logging
9
59
  */
@@ -20,6 +70,8 @@ export interface SyncInfo {
20
70
  success: boolean;
21
71
  /** Napaka, če sync ni uspel */
22
72
  error?: Error;
73
+ /** Where sync was called from (for debugging) */
74
+ calledFrom?: string;
23
75
  }
24
76
  /**
25
77
  * Konfiguracija za posamezno kolekcijo
@@ -55,11 +107,28 @@ export interface SyncedDbConfig {
55
107
  /** Timeout za sync REST klice v ms (default: 30000) - daljši ker sync prenaša več podatkov */
56
108
  syncTimeoutMs?: number;
57
109
  /** Debounce čas za zapis v Dexie v ms (default: 1000) */
58
- debounceMs?: number;
110
+ debounceDexieWritesMs?: number;
111
+ /** Debounce čas za pošiljanje na REST v ms (default: 2000) - po uspešnem zapisu v Dexie */
112
+ debounceRestWritesMs?: number;
59
113
  /** Callback ki se pokliče, ko SyncedDb sam preide v offline stanje (npr. ob sync napaki) */
60
114
  onForcedOffline?: (reason: string) => void;
61
115
  /** Callback za debugging/logging - pokliče se po vsaki sinhronizaciji */
62
116
  onSync?: (info: SyncInfo) => void;
117
+ /** Callback before sending data to server (updateCollections) */
118
+ onServerWriteRequest?: (info: ServerWriteRequestInfo) => void;
119
+ /** Callback after receiving result from server (updateCollections) */
120
+ onServerWriteResult?: (info: ServerWriteResultInfo) => void;
121
+ /** Callback when findNewerMany is called */
122
+ onFindNewerManyCall?: (info: FindNewerManyCallInfo) => void;
123
+ /** Callback when findNewer is called */
124
+ onFindNewerCall?: (info: FindNewerCallInfo) => void;
125
+ /**
126
+ * Interval za avtomatsko sinhronizacijo v ms (opcijsko).
127
+ * Če je podano, se sync() kliče avtomatsko na ta interval, ko je online.
128
+ * Auto-sync se izvaja samo ko je online in ne bo interferiral z eksplicitnimi sync() klici
129
+ * (uporablja isti syncLock mehanizem).
130
+ */
131
+ autoSyncIntervalMs?: number;
63
132
  }
64
133
  /**
65
134
  * Glavna logika za sinhronizirano bazo podatkov
@@ -131,19 +200,43 @@ export interface I_SyncedDb {
131
200
  * @returns true če je server dosegljiv, false sicer
132
201
  */
133
202
  ping(timeoutMs?: number): Promise<boolean>;
134
- /** Sproži sinhronizacijo s serverjem */
135
- sync(): Promise<void>;
203
+ /**
204
+ * Sproži sinhronizacijo s serverjem
205
+ * @param calledFrom Optional string for debugging - identifies where sync was called from
206
+ */
207
+ sync(calledFrom?: string): Promise<void>;
136
208
  /** Ali je sinhronizacija v teku */
137
209
  isSyncing(): boolean;
138
210
  /**
139
211
  * Izvede batch upsert na serverju
212
+ *
213
+ * IMPORTANT: This method intentionally does NOT update local state (Dexie or in-memory).
214
+ * This is by design - upsertBatch is meant for server-only operations where local
215
+ * state updates are not needed or will be handled separately.
216
+ *
217
+ * If you need local state to reflect the changes after calling upsertBatch(),
218
+ * call sync() afterward to fetch the updated data from the server.
219
+ *
220
+ * Note: Server notifications (via serverUpdateNotifier) for upsertBatch changes
221
+ * may trigger handleServerUpdate(), which will update local state. However, this
222
+ * happens asynchronously and may overwrite concurrent local changes if any exist.
223
+ *
140
224
  * - Deluje samo online, offline vrže napako
141
225
  * - Ne posodablja dexie ali in-mem baze
142
226
  * - Uporabljeno za operacije, ki ne potrebujejo lokalne sinhronizacije
227
+ *
228
+ * @example
229
+ * // If you need local state updated after batch operation:
230
+ * await syncedDb.upsertBatch('items', batchSpec);
231
+ * await syncedDb.sync(); // Fetch changes to local state
143
232
  */
144
233
  upsertBatch<T extends DbEntity>(collection: string, batch: BatchSpec<T>): Promise<T[]>;
145
234
  /** Vrne vse objekte iz in-mem baze za dano kolekcijo */
146
235
  getMemoryCollection<T extends DbEntity>(collection: string): T[];
147
- /** Vrne konfigurirani debounce čas v ms */
148
- getDebounceMs(): number;
236
+ /** Vrne konfigurirani debounce čas za Dexie v ms */
237
+ getDebounceDexieWritesMs(): number;
238
+ /** Vrne konfigurirani debounce čas za REST v ms */
239
+ getDebounceRestWritesMs(): number;
240
+ /** Vrne vse dirty objekte iz vseh kolekcij */
241
+ getDirty<T extends DbEntity>(): Promise<Readonly<Record<string, readonly T[]>>>;
149
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
27
27
  "bson": "^7.0.0",
28
- "cry-db": "^2.4.11",
28
+ "cry-db": "^2.4.12",
29
29
  "dexie": "^4.2.1",
30
30
  "fake-indexeddb": "^6.2.5",
31
31
  "typescript": "^5"
package/README.md DELETED
@@ -1,15 +0,0 @@
1
- # synced-db-client
2
-
3
- To install dependencies:
4
-
5
- ```bash
6
- bun install
7
- ```
8
-
9
- To run:
10
-
11
- ```bash
12
- bun run index.ts
13
- ```
14
-
15
- This project was created using `bun init` in bun v1.3.5. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.