@vue-skuilder/db 0.2.17 → 0.2.19

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.
Files changed (38) hide show
  1. package/dist/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
  2. package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
  3. package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-BDRX_YYJ.d.ts} +97 -1
  4. package/dist/{contentSource-Brz42x7n.d.cts → contentSource-D-LQYFyx.d.cts} +97 -1
  5. package/dist/core/index.d.cts +3 -3
  6. package/dist/core/index.d.ts +3 -3
  7. package/dist/core/index.js +167 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +166 -1
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-Dd2UcCif.d.cts} +1 -1
  12. package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-Dpshuql4.d.ts} +1 -1
  13. package/dist/impl/couch/index.d.cts +16 -3
  14. package/dist/impl/couch/index.d.ts +16 -3
  15. package/dist/impl/couch/index.js +199 -3
  16. package/dist/impl/couch/index.js.map +1 -1
  17. package/dist/impl/couch/index.mjs +199 -3
  18. package/dist/impl/couch/index.mjs.map +1 -1
  19. package/dist/impl/static/index.d.cts +3 -3
  20. package/dist/impl/static/index.d.ts +3 -3
  21. package/dist/impl/static/index.js +165 -1
  22. package/dist/impl/static/index.js.map +1 -1
  23. package/dist/impl/static/index.mjs +165 -1
  24. package/dist/impl/static/index.mjs.map +1 -1
  25. package/dist/index.d.cts +3 -3
  26. package/dist/index.d.ts +3 -3
  27. package/dist/index.js +201 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +200 -3
  30. package/dist/index.mjs.map +1 -1
  31. package/package.json +4 -3
  32. package/src/core/index.ts +1 -0
  33. package/src/core/interfaces/userDB.ts +24 -0
  34. package/src/core/types/hydration.ts +78 -0
  35. package/src/impl/common/BaseUserDB.ts +233 -2
  36. package/src/impl/common/SyncStrategy.ts +19 -0
  37. package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
  38. package/tests/impl/hydration.test.ts +330 -0
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.17",
7
+ "version": "0.2.19",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.17",
51
+ "@vue-skuilder/common": "0.2.19",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -57,10 +57,11 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/uuid": "^10.0.0",
60
+ "pouchdb-adapter-memory": "^9.0.0",
60
61
  "tsup": "^8.0.2",
61
62
  "typescript": "~5.9.3",
62
63
  "vite": "^8.0.0",
63
64
  "vitest": "^4.1.0"
64
65
  },
65
- "stableVersion": "0.2.17"
66
+ "stableVersion": "0.2.19"
66
67
  }
package/src/core/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './types/types-legacy';
5
5
  export * from './types/user';
6
6
  export * from './types/strategyState';
7
7
  export * from './types/userOutcome';
8
+ export * from './types/hydration';
8
9
  export * from '../util/Loggable';
9
10
  export * from './util';
10
11
  export * from './navigators';
@@ -9,6 +9,7 @@ import { Moment } from 'moment';
9
9
  import { CardHistory, CardRecord, QualifiedCardID } from '../types/types-legacy';
10
10
  import { UserOutcomeRecord } from '../types/userOutcome';
11
11
  import { UserConfig } from '../types/user';
12
+ import { UserHydrationStatus } from '../types/hydration';
12
13
  import { DocumentUpdater } from '@db/study';
13
14
 
14
15
  /**
@@ -19,6 +20,29 @@ export interface UserDBReader {
19
20
  getUsername(): string;
20
21
  isLoggedIn(): boolean;
21
22
 
23
+ /**
24
+ * How far this device's local mirror of the user's data can be trusted,
25
+ * as of right now.
26
+ *
27
+ * Reads answer from the local mirror, so on a device that has never synced
28
+ * this account "document not found" does not mean "no such data". Anything
29
+ * that would otherwise interpret an empty read as a new user should check
30
+ * for `failed` first. See {@link UserHydrationStatus}.
31
+ *
32
+ * Can return `hydrating`; use {@link awaitHydration} to decide on a settled
33
+ * answer instead of a snapshot.
34
+ */
35
+ hydrationStatus(): UserHydrationStatus;
36
+
37
+ /**
38
+ * The hydration status once settled — never `hydrating`.
39
+ *
40
+ * Resolves immediately unless a pull is in flight. Prefer this over
41
+ * hydrationStatus() wherever the answer gates behaviour, such as a route
42
+ * guard that may run while a login is still completing.
43
+ */
44
+ awaitHydration(): Promise<UserHydrationStatus>;
45
+
22
46
  /**
23
47
  * Get user configuration
24
48
  */
@@ -0,0 +1,78 @@
1
+ /**
2
+ * User database hydration.
3
+ *
4
+ * A logged-in user's data lives in a remote CouchDB `userdb-<hex>` and is
5
+ * mirrored into a browser-local PouchDB. On a device that has never synced
6
+ * that account, the local mirror starts EMPTY — and every local read
7
+ * (`getStrategyState`, `getConfig`, `getCourseRegistrationsDoc`, ...) answers
8
+ * "no such document", which is indistinguishable from "brand new user".
9
+ *
10
+ * Consumers acting on that answer render a new-user experience to an existing
11
+ * user, and — worse — compute writes from empty state that then lose to the
12
+ * real remote document at replication time. Hydration closes that window by
13
+ * pulling the account's documents down BEFORE the user object is handed out.
14
+ */
15
+
16
+ /**
17
+ * Where a user's local database stands relative to its remote counterpart.
18
+ *
19
+ * Only `failed` is a blocking condition. `stale` explicitly is not: it means a
20
+ * full pull completed on this device previously, so local data is real and
21
+ * merely possibly-behind — live sync closes the gap in the background. That is
22
+ * the ordinary offline case and it must keep working.
23
+ */
24
+ export type UserHydrationState =
25
+ /** Guest, or a data layer with no remote. There is nothing to hydrate from. */
26
+ | 'not-required'
27
+ /** Initial pull in flight. */
28
+ | 'hydrating'
29
+ /** Initial pull completed this session. Local is a faithful mirror. */
30
+ | 'hydrated'
31
+ /**
32
+ * A previous session completed a full pull on this device (recorded by the
33
+ * `_local/hydration` marker). Local data is real; live sync is catching up.
34
+ */
35
+ | 'stale'
36
+ /**
37
+ * No pull has ever completed on this device and one could not be completed
38
+ * now. Local reads CANNOT be trusted to mean "no data" — callers must not
39
+ * present a new-user experience or compute writes from what they read.
40
+ */
41
+ | 'failed';
42
+
43
+ /**
44
+ * Hydration state plus observability detail.
45
+ *
46
+ * Shaped to mirror `CourseSyncStatus` in CourseSyncService, which solves the
47
+ * same problem for course databases.
48
+ */
49
+ export interface UserHydrationStatus {
50
+ state: UserHydrationState;
51
+ /** Documents pulled by the initial replication (set when state is `hydrated`). */
52
+ docsWritten?: number;
53
+ /** Wall-clock duration of the initial pull attempt, successful or not. */
54
+ durationMs?: number;
55
+ /** Failure detail, set when state is `failed`. */
56
+ error?: string;
57
+ }
58
+
59
+ /**
60
+ * ID of the per-device marker recording that a full pull once completed.
61
+ *
62
+ * `_local/*` documents are deliberately excluded from replication by CouchDB
63
+ * and PouchDB alike, which is exactly the semantics wanted here: the marker
64
+ * describes THIS device's mirror, and must never travel to another one.
65
+ */
66
+ export const HYDRATION_MARKER_ID = '_local/hydration';
67
+
68
+ /**
69
+ * Payload of {@link HYDRATION_MARKER_ID}.
70
+ */
71
+ export interface HydrationMarker {
72
+ _id: typeof HYDRATION_MARKER_ID;
73
+ _rev?: string;
74
+ /** Account the mirror was hydrated for — guards against a stale marker after an account switch. */
75
+ username: string;
76
+ /** ISO timestamp of the most recent successful full pull. */
77
+ hydratedAt: string;
78
+ }
@@ -1,4 +1,12 @@
1
- import { DocType, DocTypePrefixes, StrategyStateDoc, buildStrategyStateId } from '@db/core';
1
+ import {
2
+ DocType,
3
+ DocTypePrefixes,
4
+ StrategyStateDoc,
5
+ buildStrategyStateId,
6
+ HYDRATION_MARKER_ID,
7
+ type HydrationMarker,
8
+ type UserHydrationStatus,
9
+ } from '@db/core';
2
10
  import { getCardHistoryID } from '@db/core/util';
3
11
  import { CourseElo, Status } from '@vue-skuilder/common';
4
12
  import moment, { Moment } from 'moment';
@@ -50,6 +58,35 @@ interface DesignDoc {
50
58
  };
51
59
  }
52
60
 
61
+ /**
62
+ * Ceiling on the initial pull of a user's data before we give up and report
63
+ * `failed`. Generous relative to the expected payload (a user database is a
64
+ * config doc, registrations, strategy state, and per-card history), so hitting
65
+ * it indicates a genuinely unusable connection rather than a large account.
66
+ */
67
+ const HYDRATION_TIMEOUT_MS = 15_000;
68
+
69
+ /**
70
+ * Reject if `p` has not settled within `ms`.
71
+ *
72
+ * Note the underlying work is not cancelled by this — callers that need the
73
+ * operation stopped must do so themselves.
74
+ */
75
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
76
+ let timer: ReturnType<typeof setTimeout> | undefined;
77
+
78
+ return Promise.race([
79
+ p,
80
+ new Promise<never>((_resolve, reject) => {
81
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
82
+ }),
83
+ ]).finally(() => {
84
+ if (timer !== undefined) {
85
+ clearTimeout(timer);
86
+ }
87
+ }) as Promise<T>;
88
+ }
89
+
53
90
  /**
54
91
  * Base user database implementation that uses a pluggable sync strategy.
55
92
  * Handles local storage operations and delegates sync/remote operations to the strategy.
@@ -87,9 +124,59 @@ export class BaseUser implements UserDBInterface, DocumentUpdater {
87
124
  private localDB!: PouchDB.Database;
88
125
  private remoteDB!: PouchDB.Database;
89
126
  private writeDB!: PouchDB.Database; // Database to use for write operations (local-first approach)
90
-
91
127
  private updateQueue!: UpdateQueue;
92
128
 
129
+ private _hydration: UserHydrationStatus = { state: 'not-required' };
130
+ /** In-flight hydration, so concurrent callers can wait for a real answer. */
131
+ private _hydrationPromise: Promise<void> | null = null;
132
+
133
+ /**
134
+ * How far the local mirror can be trusted, RIGHT NOW. See
135
+ * {@link UserHydrationStatus}.
136
+ *
137
+ * This is a snapshot, and during login it can legitimately read
138
+ * `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
139
+ * answer.
140
+ */
141
+ public hydrationStatus(): UserHydrationStatus {
142
+ return { ...this._hydration };
143
+ }
144
+
145
+ /**
146
+ * The hydration status once it has settled — never `hydrating`.
147
+ *
148
+ * Resolves immediately unless a pull is in flight. Exists for callers that
149
+ * must decide something (a route guard, a first-run branch) and would
150
+ * otherwise sample `hydrating` and guess. Since init() is awaited by both
151
+ * app startup and login(), that only happens when something navigates
152
+ * concurrently with a login still in progress — a window that stretches to
153
+ * HYDRATION_TIMEOUT_MS on a slow connection.
154
+ */
155
+ public async awaitHydration(): Promise<UserHydrationStatus> {
156
+ // hydrateLocalMirror() records failures rather than throwing, so this
157
+ // cannot reject — but a stray rejection must not become the caller's
158
+ // problem when a status is available either way.
159
+ try {
160
+ await this._hydrationPromise;
161
+ } catch {
162
+ // fall through to the recorded status
163
+ }
164
+ return this.hydrationStatus();
165
+ }
166
+
167
+ /**
168
+ * Whether a missing document may be treated as genuinely absent, allowing
169
+ * callers to create it with defaults.
170
+ *
171
+ * False only when hydration failed or is still running: a 404 then may just
172
+ * mean "not pulled down yet", and materializing a default would both lie to
173
+ * the user and, once live sync starts, push a rev-1 document that loses to
174
+ * the real one — silently discarding it.
175
+ */
176
+ private canMaterializeDefaults(): boolean {
177
+ return this._hydration.state !== 'failed' && this._hydration.state !== 'hydrating';
178
+ }
179
+
93
180
  public async createAccount(
94
181
  username: string,
95
182
  password: string
@@ -257,6 +344,14 @@ Currently logged-in as ${this._username}.`
257
344
  } catch (e) {
258
345
  const err = e as PouchError;
259
346
  if (err.status === 404) {
347
+ if (!this.canMaterializeDefaults()) {
348
+ throw new Error(
349
+ `Cannot read course registrations for ${this._username}: the local mirror is ` +
350
+ `unavailable (hydration state '${this._hydration.state}'). Refusing to create an ` +
351
+ `empty registration doc — that would report an existing user as unregistered, and ` +
352
+ `then lose to their real document once sync resumes.`
353
+ );
354
+ }
260
355
  await this.localDB.put<CourseRegistrationDoc>({
261
356
  _id: BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
262
357
  courses: [],
@@ -556,6 +651,13 @@ Currently logged-in as ${this._username}.`
556
651
  } catch (e) {
557
652
  const err = e as PouchError;
558
653
  if (err.name && err.name === 'not_found') {
654
+ if (!this.canMaterializeDefaults()) {
655
+ throw new Error(
656
+ `Cannot read config for ${this._username}: the local mirror is unavailable ` +
657
+ `(hydration state '${this._hydration.state}'). Refusing to write a default config ` +
658
+ `over what may be an existing one.`
659
+ );
660
+ }
559
661
  await this.localDB.put<UserConfig>(defaultConfig);
560
662
  return this.getConfig();
561
663
  } else {
@@ -643,8 +745,30 @@ Currently logged-in as ${this._username}.`
643
745
  return;
644
746
  }
645
747
 
748
+ // Cancel replication left over from the previous identity BEFORE rebinding
749
+ // the DB handles below. init() runs on every identity change (login,
750
+ // logout, account creation, data reset), and each one points localDB /
751
+ // remoteDB somewhere new — so without this, the prior sync keeps running
752
+ // against handles nobody holds a reference to anymore.
753
+ //
754
+ // Logout is the case that bites: the guest branch of startSync() never
755
+ // replaces the handle (it only ever assigns when a sync is actually
756
+ // started), so the departing user's live sync survived indefinitely. Its
757
+ // session cookie is gone by then, leaving it to 401-and-retry forever.
758
+ this.syncStrategy.stopSync?.();
759
+
646
760
  this.setDBandQ();
647
761
 
762
+ // Populate the local mirror BEFORE anything can read it, and before live
763
+ // sync can push local state upward. See hydrateLocalMirror().
764
+ //
765
+ // Retained (not just awaited) so awaitHydration() can hand the same
766
+ // promise to anything that asks mid-flight. Assigned synchronously with
767
+ // respect to init()'s entry, so no caller can observe a previous
768
+ // identity's promise here.
769
+ this._hydrationPromise = this.hydrateLocalMirror();
770
+ await this._hydrationPromise;
771
+
648
772
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
649
773
  this.applyDesignDocs().catch((error) => {
650
774
  log(`Error in applyDesignDocs background task: ${error}`);
@@ -661,6 +785,104 @@ Currently logged-in as ${this._username}.`
661
785
  BaseUser._initialized = true;
662
786
  }
663
787
 
788
+ /**
789
+ * Pull this account's documents into the local mirror, if that hasn't
790
+ * happened on this device before.
791
+ *
792
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
793
+ * anything can observe an empty local DB or replicate one upward.
794
+ *
795
+ * Never throws: a failure is recorded as `failed` state and reported through
796
+ * hydrationStatus(), because throwing here would abort init() and leave
797
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
798
+ * ceiling). Callers decide what a failure means for them.
799
+ */
800
+ private async hydrateLocalMirror(): Promise<void> {
801
+ // Guests and static-mode users have no remote — setupRemoteDB() hands back
802
+ // the local database itself, so there is nothing to pull from.
803
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
804
+ this._hydration = { state: 'not-required' };
805
+ return;
806
+ }
807
+
808
+ if (await this.hasHydrationMarker()) {
809
+ // A full pull already completed on this device, so local reads return
810
+ // real data and live sync will close any gap. Blocking here would break
811
+ // offline startup to re-fetch what we already have.
812
+ this._hydration = { state: 'stale' };
813
+ return;
814
+ }
815
+
816
+ this._hydration = { state: 'hydrating' };
817
+ const start = Date.now();
818
+
819
+ try {
820
+ const { docsWritten } = await withTimeout(
821
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
822
+ HYDRATION_TIMEOUT_MS,
823
+ `Hydration of local mirror for ${this._username}`
824
+ );
825
+ await this.writeHydrationMarker();
826
+ this._hydration = {
827
+ state: 'hydrated',
828
+ docsWritten,
829
+ durationMs: Date.now() - start,
830
+ };
831
+ log(
832
+ `Hydrated local mirror for ${this._username}: ` +
833
+ `${docsWritten} docs in ${this._hydration.durationMs}ms`
834
+ );
835
+ } catch (e) {
836
+ // Cancel the pull if it is merely slow rather than dead — otherwise it
837
+ // races the live sync we are about to start over the same documents.
838
+ this.syncStrategy.stopSync?.();
839
+ this._hydration = {
840
+ state: 'failed',
841
+ durationMs: Date.now() - start,
842
+ error: e instanceof Error ? e.message : String(e),
843
+ };
844
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Has a full pull completed on this device for the CURRENT account?
850
+ *
851
+ * The marker is a `_local/` document, which never replicates — so its
852
+ * presence describes this device's mirror and cannot arrive from elsewhere.
853
+ */
854
+ private async hasHydrationMarker(): Promise<boolean> {
855
+ try {
856
+ const marker = await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID);
857
+ return marker.username === this._username;
858
+ } catch {
859
+ // Absent, or unreadable — either way, treat as never hydrated. The cost
860
+ // of a false negative is one redundant pull.
861
+ return false;
862
+ }
863
+ }
864
+
865
+ private async writeHydrationMarker(): Promise<void> {
866
+ try {
867
+ let existingRev: string | undefined;
868
+ try {
869
+ existingRev = (await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID))._rev;
870
+ } catch {
871
+ // First hydration on this device.
872
+ }
873
+
874
+ await this.localDB.put<HydrationMarker>({
875
+ _id: HYDRATION_MARKER_ID,
876
+ _rev: existingRev,
877
+ username: this._username,
878
+ hydratedAt: new Date().toISOString(),
879
+ });
880
+ } catch (e) {
881
+ // Costs a redundant pull on next startup, not correctness.
882
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
883
+ }
884
+ }
885
+
664
886
  private static designDocs: DesignDoc[] = [
665
887
  {
666
888
  _id: '_design/reviewCards',
@@ -1083,6 +1305,15 @@ Currently logged-in as ${this._username}.`
1083
1305
  } catch (e) {
1084
1306
  const err = e as PouchError;
1085
1307
  if (err.status === 404) {
1308
+ // `null` here means "this user has no such state" — a first-run signal
1309
+ // consumers act on. When the mirror is untrusted we cannot honestly say
1310
+ // that, so fail instead of reporting an established user as new.
1311
+ if (!this.canMaterializeDefaults()) {
1312
+ throw new Error(
1313
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror ` +
1314
+ `is unavailable (hydration state '${this._hydration.state}').`
1315
+ );
1316
+ }
1086
1317
  return null;
1087
1318
  }
1088
1319
  throw e;
@@ -21,6 +21,25 @@ export interface SyncStrategy {
21
21
  */
22
22
  getWriteDB?(username: string): PouchDB.Database;
23
23
 
24
+ /**
25
+ * Pull the remote database into the local one and RESOLVE WHEN DONE.
26
+ *
27
+ * Distinct from startSync(): this is a one-shot, awaitable catch-up used to
28
+ * populate an empty local mirror before the user object is handed out. A
29
+ * strategy that omits it declares that it has no remote worth waiting for.
30
+ *
31
+ * Mechanism only — BaseUser owns the policy (whether to run it, timeout,
32
+ * marker bookkeeping, resulting hydration state).
33
+ *
34
+ * @param localDB The local PouchDB instance
35
+ * @param remoteDB The remote PouchDB instance
36
+ * @returns Count of documents written locally
37
+ */
38
+ hydrate?(
39
+ localDB: PouchDB.Database,
40
+ remoteDB: PouchDB.Database
41
+ ): Promise<{ docsWritten: number }>;
42
+
24
43
  /**
25
44
  * Start synchronization between local and remote databases
26
45
  * @param localDB The local PouchDB instance
@@ -21,6 +21,8 @@ const log = (s: any) => {
21
21
  */
22
22
  export class CouchDBSyncStrategy implements SyncStrategy {
23
23
  private syncHandle?: any; // Handle to cancel sync if needed
24
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
25
+ private hydrationHandle?: PouchDB.Replication.Replication<object>;
24
26
 
25
27
  setupRemoteDB(username: string): PouchDB.Database {
26
28
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
@@ -42,20 +44,68 @@ export class CouchDBSyncStrategy implements SyncStrategy {
42
44
  }
43
45
  }
44
46
 
47
+ /**
48
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
49
+ *
50
+ * Pull only: pushing here would upload whatever the local DB happens to hold
51
+ * before we know what the remote already has, which is the conflict-leaf
52
+ * problem hydration exists to avoid. Local changes go up when startSync()
53
+ * takes over.
54
+ */
55
+ async hydrate(
56
+ localDB: PouchDB.Database,
57
+ remoteDB: PouchDB.Database
58
+ ): Promise<{ docsWritten: number }> {
59
+ // A one-shot Replication is itself a promise of the completed result, so
60
+ // it can be awaited directly — the handle is retained only so a pull that
61
+ // outlives its timeout can be cancelled.
62
+ const replication = pouch.replicate(remoteDB, localDB, {});
63
+ this.hydrationHandle = replication;
64
+
65
+ try {
66
+ const info = await replication;
67
+ return { docsWritten: info.docs_written };
68
+ } finally {
69
+ this.hydrationHandle = undefined;
70
+ }
71
+ }
72
+
45
73
  startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void {
46
- // Only sync if local and remote are different instances
47
- if (localDB !== remoteDB) {
74
+ // Compare by NAME, not object identity. In guest mode setupRemoteDB()
75
+ // returns getLocalUserDB(username) — the same database as localDB but
76
+ // getLocalUserDB() constructs a fresh PouchDB handle on every call rather
77
+ // than caching, so `localDB !== remoteDB` was always true and guests ran a
78
+ // live sync of a database against itself. Names are unambiguous here:
79
+ // local DBs are named `userdb-<username>` (or a filesystem path in Node),
80
+ // remote ones are a full `protocol://host/userdb-<hex>` URL.
81
+ if (localDB.name !== remoteDB.name) {
48
82
  this.syncHandle = pouch.sync(localDB, remoteDB, {
49
83
  live: true,
50
84
  retry: true,
51
85
  });
52
86
  }
53
- // If they're the same (guest mode), no sync needed
87
+ // If they're the same DB (guest mode), no sync needed
54
88
  }
55
89
 
56
90
  stopSync?(): void {
91
+ if (this.hydrationHandle) {
92
+ try {
93
+ this.hydrationHandle.cancel();
94
+ } catch (e) {
95
+ logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
96
+ }
97
+ this.hydrationHandle = undefined;
98
+ }
99
+
57
100
  if (this.syncHandle) {
58
- this.syncHandle.cancel();
101
+ // Called from BaseUser.init(), which is on the app boot path — a throw
102
+ // here would leave BaseUser._initialized false forever, and
103
+ // BaseUser.instance() polls that flag. Drop the handle regardless.
104
+ try {
105
+ this.syncHandle.cancel();
106
+ } catch (e) {
107
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
108
+ }
59
109
  this.syncHandle = undefined;
60
110
  }
61
111
  }