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.
- package/dist/db/RestProxy.d.ts +2 -6
- package/dist/db/SyncedDb.d.ts +49 -6
- package/dist/index.js +233 -78
- package/dist/types/I_RestInterface.d.ts +34 -4
- package/dist/types/I_SyncedDb.d.ts +100 -7
- package/package.json +2 -2
- package/README.md +0 -15
package/dist/db/RestProxy.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Timestamp, AggregateOptions } from "mongodb";
|
|
2
|
-
import type {
|
|
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
|
}
|
package/dist/db/SyncedDb.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
681
|
-
return this.
|
|
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.
|
|
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
|
|
771
|
-
|
|
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
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
973
|
+
if (this.onServerWriteRequest) {
|
|
844
974
|
try {
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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(
|
|
981
|
+
console.error("onServerWriteRequest callback failed:", err);
|
|
859
982
|
}
|
|
860
983
|
}
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|