cry-synced-db-client 0.1.170 → 0.1.171

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/CHANGELOG.md CHANGED
@@ -2,6 +2,102 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Runtime collection registration (`addCollectionToSync`, `replaceSyncCollection`)
6
+
7
+ Two methods to install / replace collection configs at runtime; both load the
8
+ Dexie cursor into the sync-meta cache, hydrate in-mem from Dexie, extend an
9
+ active `syncOnlyTheseCollections` filter, and fire a one-shot **targeted**
10
+ download for just that collection (`syncCollectionForFind` →
11
+ `processCollectionServerData`, cursor advances inline). Other collections
12
+ are not re-fetched.
13
+
14
+ **`addCollectionToSync(spec)` — safe, idempotent** (Promise<void>)
15
+
16
+ | Existing config | Action |
17
+ |---|---|
18
+ | permanent (`temporaryConfig !== true`) | no-op |
19
+ | temporary (`temporaryConfig === true`) | replaced |
20
+ | none | added |
21
+
22
+ **`replaceSyncCollection(spec)` — explicit re-config** (Promise<boolean>)
23
+
24
+ | Existing | New | Result |
25
+ |---|---|---|
26
+ | none | any | install, returns `true` |
27
+ | temporary | any | replace, returns `true` |
28
+ | permanent | permanent | replace, returns `true` |
29
+ | permanent | temporary | **blocked, returns `false`** (no-op) |
30
+
31
+ The only blocked transition is permanent → temporary (cannot downgrade an
32
+ established config to provisional).
33
+
34
+ ```typescript
35
+ // Boot with a provisional config so the Dexie schema includes the
36
+ // collection but the real query/conflict-resolver is deferred.
37
+ new SyncedDb({ collections: [{ name: "obravnave", temporaryConfig: true }] });
38
+
39
+ // Idempotent install after login:
40
+ await syncedDb.addCollectionToSync({
41
+ name: "obravnave",
42
+ syncConfig: { query: () => ({ lokacija_id: currentLokacijaId }) },
43
+ });
44
+
45
+ // Later, user switches lokacija — explicit re-config:
46
+ const ok = await syncedDb.replaceSyncCollection({
47
+ name: "obravnave",
48
+ syncConfig: { query: () => ({ lokacija_id: newLokacijaId }) },
49
+ });
50
+ // ok === true (replacement of permanent with permanent allowed)
51
+ ```
52
+
53
+ ### `flushToServer()` + automatic flush on `visibilitychange:hidden`
54
+
55
+ `SyncedDb.flushToServer(calledFrom?)` pushes dirty data to the server
56
+ immediately, bypassing `debounceRestWritesMs`. Flow: cancel REST upload
57
+ debounce → `flushAll()` (pending Dexie writes) → `awaitRestUpload()`
58
+ (in-flight upload) → `uploadDirtyItems(calledFrom)`. No-op when offline,
59
+ forced offline, or pre-`init`.
60
+
61
+ A `visibilitychange` listener fires `flushToServer("visibility-hidden")`
62
+ automatically when the tab becomes `hidden`. Browser may throttle or
63
+ suspend a hidden tab shortly after the event, so the debounced upload
64
+ could otherwise never run.
65
+
66
+ ```typescript
67
+ // Manual flush (e.g. before navigation)
68
+ await syncedDb.flushToServer("pre-navigate");
69
+
70
+ // Automatic: no caller action required. Listener registered in init(),
71
+ // cleaned up in close().
72
+ ```
73
+
74
+ Upload itself is **not gated on `isLeader`** — `PendingChangesManager`
75
+ guards only on `canSync()` — so followers also drain their dirty queue
76
+ on hidden. Cancelling the timer cancels only the *current* pending burst:
77
+ subsequent writes schedule a new timer and the auto-sync interval still
78
+ fires. To permanently stop uploads, call `close()` or `forceOffline(true)`.
79
+
80
+ ### Leadership transition timestamps (`leaderSince` / `followerSince`)
81
+
82
+ Two new getters on `SyncedDb` (and `I_LeaderElectionManager`):
83
+
84
+ - `leaderSince(): Date | undefined` — when this tab acquired the Web Locks
85
+ leader lock; `undefined` while a follower
86
+ - `followerSince(): Date | undefined` — when this tab transitioned to
87
+ follower (or, for tabs that never claimed leadership, the construction
88
+ timestamp); `undefined` while the leader
89
+
90
+ Exactly one of the two is defined at any time after construction. Useful
91
+ for heartbeat payloads / debug overlays (`Date.now() - leaderSince()` =
92
+ "time in current leadership state").
93
+
94
+ ```typescript
95
+ const isLeader = syncedDb.isLeaderTab();
96
+ const sinceMs = isLeader
97
+ ? Date.now() - syncedDb.leaderSince()!.getTime()
98
+ : Date.now() - syncedDb.followerSince()!.getTime();
99
+ ```
100
+
5
101
  ### `onServerSyncWrite` callback
6
102
 
7
103
  Single-shot callback that fires once per `restInterface.updateCollections`
package/dist/index.js CHANGED
@@ -876,6 +876,7 @@ var LeaderElectionManager = class {
876
876
  this.isLeaderFlag = false;
877
877
  this.closing = false;
878
878
  this.releasingDueToVisibility = false;
879
+ this._followerSince = /* @__PURE__ */ new Date();
879
880
  this.tenant = config.tenant;
880
881
  this.windowId = config.windowId;
881
882
  this.callbacks = config.callbacks;
@@ -973,6 +974,21 @@ var LeaderElectionManager = class {
973
974
  isLeader() {
974
975
  return this.isLeaderFlag;
975
976
  }
977
+ /**
978
+ * Returns the timestamp when this tab became leader, or undefined if currently a follower.
979
+ * Cleared on transition to follower.
980
+ */
981
+ leaderSince() {
982
+ return this._leaderSince;
983
+ }
984
+ /**
985
+ * Returns the timestamp when this tab became follower, or undefined if currently a leader.
986
+ * Initialized at construction (every tab starts as a follower until it claims the lock).
987
+ * Cleared on transition to leader.
988
+ */
989
+ followerSince() {
990
+ return this._followerSince;
991
+ }
976
992
  /**
977
993
  * Cleanup resources.
978
994
  */
@@ -1024,6 +1040,8 @@ var LeaderElectionManager = class {
1024
1040
  }
1025
1041
  }
1026
1042
  callOnBecameLeader() {
1043
+ this._leaderSince = /* @__PURE__ */ new Date();
1044
+ this._followerSince = void 0;
1027
1045
  if (this.callbacks.onBecameLeader) {
1028
1046
  try {
1029
1047
  this.callbacks.onBecameLeader();
@@ -1033,6 +1051,8 @@ var LeaderElectionManager = class {
1033
1051
  }
1034
1052
  }
1035
1053
  callOnLostLeadership() {
1054
+ this._followerSince = /* @__PURE__ */ new Date();
1055
+ this._leaderSince = void 0;
1036
1056
  if (this.callbacks.onLostLeadership) {
1037
1057
  try {
1038
1058
  this.callbacks.onLostLeadership();
@@ -4499,6 +4519,55 @@ var _SyncedDb = class _SyncedDb {
4499
4519
  isLeaderTab() {
4500
4520
  return this.leaderElection.isLeader();
4501
4521
  }
4522
+ leaderSince() {
4523
+ return this.leaderElection.leaderSince();
4524
+ }
4525
+ followerSince() {
4526
+ return this.leaderElection.followerSince();
4527
+ }
4528
+ /**
4529
+ * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
4530
+ */
4531
+ async addCollectionToSync(spec) {
4532
+ const existing = this.collections.get(spec.name);
4533
+ if (existing && !existing.temporaryConfig) {
4534
+ return;
4535
+ }
4536
+ await this._installCollectionConfig(spec);
4537
+ }
4538
+ /**
4539
+ * Replace the collection config and re-sync. See `I_SyncedDb.replaceSyncCollection`.
4540
+ */
4541
+ async replaceSyncCollection(spec) {
4542
+ const existing = this.collections.get(spec.name);
4543
+ if (existing && !existing.temporaryConfig && spec.temporaryConfig) {
4544
+ return false;
4545
+ }
4546
+ await this._installCollectionConfig(spec);
4547
+ return true;
4548
+ }
4549
+ /**
4550
+ * Shared install path for addCollectionToSync / replaceSyncCollection:
4551
+ * write config, extend active sync filter, load Dexie cursor + in-mem,
4552
+ * fire a targeted one-shot download for just this collection.
4553
+ */
4554
+ async _installCollectionConfig(spec) {
4555
+ var _a;
4556
+ this.collections.set(spec.name, spec);
4557
+ if (this.syncOnlyCollections) {
4558
+ this.syncOnlyCollections.add(spec.name);
4559
+ }
4560
+ const meta = await this.dexieDb.getSyncMeta(spec.name);
4561
+ if (meta) this.syncMetaCache.set(spec.name, meta);
4562
+ if (!spec.writeOnly) {
4563
+ await this.loadCollectionToInMem(spec.name);
4564
+ }
4565
+ if (!spec.writeOnly && this.connectionManager.canSync()) {
4566
+ const rawQuery = (_a = spec.syncConfig) == null ? void 0 : _a.query;
4567
+ const query = typeof rawQuery === "function" ? rawQuery() : rawQuery;
4568
+ await this.syncCollectionForFind(spec.name, query, { returnDeleted: true });
4569
+ }
4570
+ }
4502
4571
  /**
4503
4572
  * Restrict sync to only these collections. When non-empty, only the listed
4504
4573
  * collections load from Dexie→in-mem and download from server. Pass an empty
@@ -4616,6 +4685,15 @@ var _SyncedDb = class _SyncedDb {
4616
4685
  };
4617
4686
  window.addEventListener("beforeunload", this.beforeUnloadHandler);
4618
4687
  }
4688
+ if (typeof document !== "undefined") {
4689
+ this.visibilityFlushHandler = () => {
4690
+ if (document.visibilityState !== "hidden") return;
4691
+ this.flushToServer("visibility-hidden").catch((err) => {
4692
+ console.warn("flushToServer on visibility-hidden failed:", err == null ? void 0 : err.message);
4693
+ });
4694
+ };
4695
+ document.addEventListener("visibilitychange", this.visibilityFlushHandler);
4696
+ }
4619
4697
  this.initialized = true;
4620
4698
  }
4621
4699
  /**
@@ -4626,6 +4704,25 @@ var _SyncedDb = class _SyncedDb {
4626
4704
  async flush() {
4627
4705
  await this.pendingChanges.flushAll();
4628
4706
  }
4707
+ /**
4708
+ * Push dirty data to the server immediately, bypassing the upload debounce.
4709
+ *
4710
+ * Flushes pending Dexie writes, awaits any in-flight REST upload, then fires
4711
+ * a fresh `uploadDirtyItems()`. Called automatically when the tab becomes
4712
+ * hidden so data is not stranded by browser tab-throttling / suspension.
4713
+ *
4714
+ * No-op when offline, forced offline, or not yet initialized.
4715
+ *
4716
+ * @param calledFrom Diagnostic tag threaded through to upload callbacks
4717
+ */
4718
+ async flushToServer(calledFrom) {
4719
+ if (!this.initialized) return;
4720
+ if (!this.connectionManager.canSync()) return;
4721
+ this.pendingChanges.cancelRestUploadTimer();
4722
+ await this.pendingChanges.flushAll();
4723
+ await this.pendingChanges.awaitRestUpload();
4724
+ await this.syncEngine.uploadDirtyItems(calledFrom);
4725
+ }
4629
4726
  /**
4630
4727
  * Returns when all collections were last successfully synced
4631
4728
  * from the server, or undefined if never.
@@ -4706,6 +4803,10 @@ var _SyncedDb = class _SyncedDb {
4706
4803
  window.removeEventListener("beforeunload", this.beforeUnloadHandler);
4707
4804
  this.beforeUnloadHandler = void 0;
4708
4805
  }
4806
+ if (typeof document !== "undefined" && this.visibilityFlushHandler) {
4807
+ document.removeEventListener("visibilitychange", this.visibilityFlushHandler);
4808
+ this.visibilityFlushHandler = void 0;
4809
+ }
4709
4810
  this.syncMetaCache.clear();
4710
4811
  for (const collectionName of this.collections.keys()) {
4711
4812
  this.inMemManager.clearCollection(collectionName);
@@ -1,5 +1,5 @@
1
1
  import type { AggregateOptions } from "mongodb";
2
- import type { I_SyncedDb, SyncedDbConfig, WsNotificationInfo, EvictionInfo, EvictionCollectionInfo } from "../types/I_SyncedDb";
2
+ import type { I_SyncedDb, SyncedDbConfig, CollectionConfig, WsNotificationInfo, EvictionInfo, EvictionCollectionInfo } from "../types/I_SyncedDb";
3
3
  import type { DirtyMeta, MetaUpdateBroadcast } from "../types/I_DexieDb";
4
4
  import type { QuerySpec, QueryOpts, UpdateSpec, InsertSpec, BatchSpec } from "../types/I_RestInterface";
5
5
  import type { Id, DbEntity } from "../types/DbEntity";
@@ -36,6 +36,7 @@ export declare class SyncedDb implements I_SyncedDb {
36
36
  private unsubscribeServerUpdates?;
37
37
  private cleanupNotifierCallbacks?;
38
38
  private beforeUnloadHandler?;
39
+ private visibilityFlushHandler?;
39
40
  private readonly defaultReturnDeleted;
40
41
  private readonly defaultReturnArchived;
41
42
  private readonly onDatabaseCreated?;
@@ -62,6 +63,22 @@ export declare class SyncedDb implements I_SyncedDb {
62
63
  getInstanceId(): string;
63
64
  getCrossTabSyncDebounceMs(): number;
64
65
  isLeaderTab(): boolean;
66
+ leaderSince(): Date | undefined;
67
+ followerSince(): Date | undefined;
68
+ /**
69
+ * Register a collection for sync at runtime. See `I_SyncedDb.addCollectionToSync`.
70
+ */
71
+ addCollectionToSync(spec: CollectionConfig): Promise<void>;
72
+ /**
73
+ * Replace the collection config and re-sync. See `I_SyncedDb.replaceSyncCollection`.
74
+ */
75
+ replaceSyncCollection(spec: CollectionConfig): Promise<boolean>;
76
+ /**
77
+ * Shared install path for addCollectionToSync / replaceSyncCollection:
78
+ * write config, extend active sync filter, load Dexie cursor + in-mem,
79
+ * fire a targeted one-shot download for just this collection.
80
+ */
81
+ private _installCollectionConfig;
65
82
  /**
66
83
  * Restrict sync to only these collections. When non-empty, only the listed
67
84
  * collections load from Dexie→in-mem and download from server. Pass an empty
@@ -87,6 +104,18 @@ export declare class SyncedDb implements I_SyncedDb {
87
104
  * Does NOT upload to server — call sync() for that.
88
105
  */
89
106
  flush(): Promise<void>;
107
+ /**
108
+ * Push dirty data to the server immediately, bypassing the upload debounce.
109
+ *
110
+ * Flushes pending Dexie writes, awaits any in-flight REST upload, then fires
111
+ * a fresh `uploadDirtyItems()`. Called automatically when the tab becomes
112
+ * hidden so data is not stranded by browser tab-throttling / suspension.
113
+ *
114
+ * No-op when offline, forced offline, or not yet initialized.
115
+ *
116
+ * @param calledFrom Diagnostic tag threaded through to upload callbacks
117
+ */
118
+ flushToServer(calledFrom?: string): Promise<void>;
90
119
  private _lastFullSyncDate?;
91
120
  private _lastInitialSyncDate?;
92
121
  /**
@@ -12,6 +12,8 @@ export declare class LeaderElectionManager implements I_LeaderElectionManager {
12
12
  private isLeaderFlag;
13
13
  private closing;
14
14
  private releasingDueToVisibility;
15
+ private _leaderSince?;
16
+ private _followerSince?;
15
17
  private releaseLeaderLockResolve?;
16
18
  private becameLeaderPromise?;
17
19
  private becameLeaderResolve?;
@@ -36,6 +38,17 @@ export declare class LeaderElectionManager implements I_LeaderElectionManager {
36
38
  * Check if this instance is currently the leader.
37
39
  */
38
40
  isLeader(): boolean;
41
+ /**
42
+ * Returns the timestamp when this tab became leader, or undefined if currently a follower.
43
+ * Cleared on transition to follower.
44
+ */
45
+ leaderSince(): Date | undefined;
46
+ /**
47
+ * Returns the timestamp when this tab became follower, or undefined if currently a leader.
48
+ * Initialized at construction (every tab starts as a follower until it claims the lock).
49
+ * Cleared on transition to leader.
50
+ */
51
+ followerSince(): Date | undefined;
39
52
  /**
40
53
  * Cleanup resources.
41
54
  */
@@ -26,6 +26,10 @@ export interface I_LeaderElectionManager {
26
26
  releaseLeaderLock(): void;
27
27
  /** Check if this instance is currently the leader. */
28
28
  isLeader(): boolean;
29
+ /** Timestamp when this tab became leader, or undefined if currently a follower. */
30
+ leaderSince(): Date | undefined;
31
+ /** Timestamp when this tab became follower, or undefined if currently a leader. */
32
+ followerSince(): Date | undefined;
29
33
  /** Initialize (setup visibility listeners, BroadcastChannel). */
30
34
  init(): void;
31
35
  /** Cleanup resources. */
@@ -402,6 +402,13 @@ export interface CollectionConfig<T extends DbEntity = any, M = any> {
402
402
  * Read operations throw. Server sync and WS notifications are skipped.
403
403
  */
404
404
  writeOnly?: boolean;
405
+ /**
406
+ * Marks this config as provisional — a later `addCollectionToSync()` with
407
+ * a permanent (non-temporary) config will replace it. Permanent configs
408
+ * (the default) are immutable: subsequent `addCollectionToSync()` calls
409
+ * for the same collection are no-ops once a permanent config is in place.
410
+ */
411
+ temporaryConfig?: boolean;
405
412
  /** Whether this collection uses in-memory metadata */
406
413
  hasMetadata?: boolean;
407
414
  /** Callback called when a single object is written to in-mem. Returns metadata to store. */
@@ -737,6 +744,53 @@ export interface I_SyncedDb {
737
744
  * Does NOT upload to server — call sync() for that.
738
745
  */
739
746
  flush(): Promise<void>;
747
+ /**
748
+ * Push dirty data to the server immediately, bypassing the upload debounce.
749
+ * Flushes pending Dexie writes, awaits any in-flight REST upload, then fires
750
+ * a fresh upload. Called automatically on `visibilitychange` to `hidden` so
751
+ * data is not stranded by browser tab-throttling / suspension.
752
+ * No-op when offline, forced offline, or not yet initialized.
753
+ * @param calledFrom Diagnostic tag threaded through to upload callbacks
754
+ */
755
+ flushToServer(calledFrom?: string): Promise<void>;
756
+ /**
757
+ * Register a collection for sync at runtime.
758
+ *
759
+ * Behavior depends on whether a config already exists for `spec.name`:
760
+ * - permanent (existing config has `temporaryConfig !== true`): no-op
761
+ * - temporary (existing config has `temporaryConfig === true`): replaced by `spec`
762
+ * - none: `spec` is added
763
+ *
764
+ * On add or replace: loads the Dexie cursor for this collection into the
765
+ * sync-meta cache, hydrates in-mem from Dexie, and (when online + not
766
+ * `writeOnly`) fires a one-shot download sync for just this collection.
767
+ * If a `syncOnlyTheseCollections` filter is active, the new collection is
768
+ * added to it so future syncs include it.
769
+ *
770
+ * @param spec Collection config to register
771
+ */
772
+ addCollectionToSync(spec: CollectionConfig): Promise<void>;
773
+ /**
774
+ * Replace the collection config and re-sync.
775
+ *
776
+ * Like `addCollectionToSync` but more aggressive — overrides even a
777
+ * permanent existing config. The only blocked transition is
778
+ * **permanent → temporary** (cannot downgrade an already-established
779
+ * config to provisional):
780
+ *
781
+ * - no existing config: install `spec`, return `true`
782
+ * - existing temporary: replace with `spec` (any type), return `true`
783
+ * - existing permanent, `spec.temporaryConfig === true`: return `false`, no-op
784
+ * - existing permanent, `spec` permanent: replace with `spec`, return `true`
785
+ *
786
+ * On replace: extends active `syncOnlyTheseCollections` filter, reloads
787
+ * the Dexie cursor, re-hydrates in-mem, and (when online + not `writeOnly`)
788
+ * fires a one-shot targeted download.
789
+ *
790
+ * @param spec Collection config to install
791
+ * @returns `true` if installed, `false` if blocked (permanent → temporary)
792
+ */
793
+ replaceSyncCollection(spec: CollectionConfig): Promise<boolean>;
740
794
  /**
741
795
  * Returns when all collections were last successfully synced
742
796
  * from the server, or undefined if never synced.
@@ -954,6 +1008,19 @@ export interface I_SyncedDb {
954
1008
  * @returns true if this instance holds the leader lock
955
1009
  */
956
1010
  isLeaderTab(): boolean;
1011
+ /**
1012
+ * Timestamp when this tab became the leader.
1013
+ * Set on transition from follower to leader; cleared when leadership is lost.
1014
+ * @returns Date of leader transition, or undefined if currently a follower
1015
+ */
1016
+ leaderSince(): Date | undefined;
1017
+ /**
1018
+ * Timestamp when this tab became a follower.
1019
+ * Initialized at construction (every tab starts as a follower until it claims
1020
+ * the lock); cleared when leadership is gained, set again on transition to follower.
1021
+ * @returns Date of follower transition, or undefined if currently the leader
1022
+ */
1023
+ followerSince(): Date | undefined;
957
1024
  /**
958
1025
  * Get metadata for a single object.
959
1026
  * @param collection Collection name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cry-synced-db-client",
3
- "version": "0.1.170",
3
+ "version": "0.1.171",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",