@vue-skuilder/db 0.2.17 → 0.2.18
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/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
- package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
- package/dist/{contentSource-Brz42x7n.d.cts → contentSource-CBqZCoGU.d.cts} +85 -1
- package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-CudEz5Tm.d.ts} +85 -1
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +146 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +145 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-BBA8tJNx.d.cts} +1 -1
- package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-C9WgkBzR.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +16 -3
- package/dist/impl/couch/index.d.ts +16 -3
- package/dist/impl/couch/index.js +178 -3
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +178 -3
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -3
- package/dist/impl/static/index.d.ts +3 -3
- package/dist/impl/static/index.js +144 -1
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +144 -1
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +180 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +179 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/userDB.ts +11 -0
- package/src/core/types/hydration.ts +78 -0
- package/src/impl/common/BaseUserDB.ts +202 -2
- package/src/impl/common/SyncStrategy.ts +19 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
- package/tests/impl/hydration.test.ts +281 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.18",
|
|
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.
|
|
51
|
+
"@vue-skuilder/common": "0.2.18",
|
|
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.
|
|
66
|
+
"stableVersion": "0.2.18"
|
|
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,16 @@ 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
|
+
*
|
|
26
|
+
* Reads answer from the local mirror, so on a device that has never synced
|
|
27
|
+
* this account "document not found" does not mean "no such data". Anything
|
|
28
|
+
* that would otherwise interpret an empty read as a new user should check
|
|
29
|
+
* for `failed` first. See {@link UserHydrationStatus}.
|
|
30
|
+
*/
|
|
31
|
+
hydrationStatus(): UserHydrationStatus;
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* Get user configuration
|
|
24
35
|
*/
|
|
@@ -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 {
|
|
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,34 @@ 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
|
+
|
|
131
|
+
/**
|
|
132
|
+
* How far the local mirror can be trusted. See {@link UserHydrationStatus}.
|
|
133
|
+
*
|
|
134
|
+
* Consumers that would otherwise read a 404 as "new user" — onboarding
|
|
135
|
+
* gates, first-run experiences, progress dashboards — should check for
|
|
136
|
+
* `failed` and present a retry affordance rather than an empty state.
|
|
137
|
+
*/
|
|
138
|
+
public hydrationStatus(): UserHydrationStatus {
|
|
139
|
+
return { ...this._hydration };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a missing document may be treated as genuinely absent, allowing
|
|
144
|
+
* callers to create it with defaults.
|
|
145
|
+
*
|
|
146
|
+
* False only when hydration failed or is still running: a 404 then may just
|
|
147
|
+
* mean "not pulled down yet", and materializing a default would both lie to
|
|
148
|
+
* the user and, once live sync starts, push a rev-1 document that loses to
|
|
149
|
+
* the real one — silently discarding it.
|
|
150
|
+
*/
|
|
151
|
+
private canMaterializeDefaults(): boolean {
|
|
152
|
+
return this._hydration.state !== 'failed' && this._hydration.state !== 'hydrating';
|
|
153
|
+
}
|
|
154
|
+
|
|
93
155
|
public async createAccount(
|
|
94
156
|
username: string,
|
|
95
157
|
password: string
|
|
@@ -257,6 +319,14 @@ Currently logged-in as ${this._username}.`
|
|
|
257
319
|
} catch (e) {
|
|
258
320
|
const err = e as PouchError;
|
|
259
321
|
if (err.status === 404) {
|
|
322
|
+
if (!this.canMaterializeDefaults()) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`Cannot read course registrations for ${this._username}: the local mirror is ` +
|
|
325
|
+
`unavailable (hydration state '${this._hydration.state}'). Refusing to create an ` +
|
|
326
|
+
`empty registration doc — that would report an existing user as unregistered, and ` +
|
|
327
|
+
`then lose to their real document once sync resumes.`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
260
330
|
await this.localDB.put<CourseRegistrationDoc>({
|
|
261
331
|
_id: BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
|
|
262
332
|
courses: [],
|
|
@@ -556,6 +626,13 @@ Currently logged-in as ${this._username}.`
|
|
|
556
626
|
} catch (e) {
|
|
557
627
|
const err = e as PouchError;
|
|
558
628
|
if (err.name && err.name === 'not_found') {
|
|
629
|
+
if (!this.canMaterializeDefaults()) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`Cannot read config for ${this._username}: the local mirror is unavailable ` +
|
|
632
|
+
`(hydration state '${this._hydration.state}'). Refusing to write a default config ` +
|
|
633
|
+
`over what may be an existing one.`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
559
636
|
await this.localDB.put<UserConfig>(defaultConfig);
|
|
560
637
|
return this.getConfig();
|
|
561
638
|
} else {
|
|
@@ -643,8 +720,24 @@ Currently logged-in as ${this._username}.`
|
|
|
643
720
|
return;
|
|
644
721
|
}
|
|
645
722
|
|
|
723
|
+
// Cancel replication left over from the previous identity BEFORE rebinding
|
|
724
|
+
// the DB handles below. init() runs on every identity change (login,
|
|
725
|
+
// logout, account creation, data reset), and each one points localDB /
|
|
726
|
+
// remoteDB somewhere new — so without this, the prior sync keeps running
|
|
727
|
+
// against handles nobody holds a reference to anymore.
|
|
728
|
+
//
|
|
729
|
+
// Logout is the case that bites: the guest branch of startSync() never
|
|
730
|
+
// replaces the handle (it only ever assigns when a sync is actually
|
|
731
|
+
// started), so the departing user's live sync survived indefinitely. Its
|
|
732
|
+
// session cookie is gone by then, leaving it to 401-and-retry forever.
|
|
733
|
+
this.syncStrategy.stopSync?.();
|
|
734
|
+
|
|
646
735
|
this.setDBandQ();
|
|
647
736
|
|
|
737
|
+
// Populate the local mirror BEFORE anything can read it, and before live
|
|
738
|
+
// sync can push local state upward. See hydrateLocalMirror().
|
|
739
|
+
await this.hydrateLocalMirror();
|
|
740
|
+
|
|
648
741
|
this.syncStrategy.startSync(this.localDB, this.remoteDB);
|
|
649
742
|
this.applyDesignDocs().catch((error) => {
|
|
650
743
|
log(`Error in applyDesignDocs background task: ${error}`);
|
|
@@ -661,6 +754,104 @@ Currently logged-in as ${this._username}.`
|
|
|
661
754
|
BaseUser._initialized = true;
|
|
662
755
|
}
|
|
663
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Pull this account's documents into the local mirror, if that hasn't
|
|
759
|
+
* happened on this device before.
|
|
760
|
+
*
|
|
761
|
+
* Runs between setDBandQ() and startSync() — after the handles exist, before
|
|
762
|
+
* anything can observe an empty local DB or replicate one upward.
|
|
763
|
+
*
|
|
764
|
+
* Never throws: a failure is recorded as `failed` state and reported through
|
|
765
|
+
* hydrationStatus(), because throwing here would abort init() and leave
|
|
766
|
+
* BaseUser._initialized false forever (BaseUser.instance() polls it with no
|
|
767
|
+
* ceiling). Callers decide what a failure means for them.
|
|
768
|
+
*/
|
|
769
|
+
private async hydrateLocalMirror(): Promise<void> {
|
|
770
|
+
// Guests and static-mode users have no remote — setupRemoteDB() hands back
|
|
771
|
+
// the local database itself, so there is nothing to pull from.
|
|
772
|
+
if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
|
|
773
|
+
this._hydration = { state: 'not-required' };
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (await this.hasHydrationMarker()) {
|
|
778
|
+
// A full pull already completed on this device, so local reads return
|
|
779
|
+
// real data and live sync will close any gap. Blocking here would break
|
|
780
|
+
// offline startup to re-fetch what we already have.
|
|
781
|
+
this._hydration = { state: 'stale' };
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
this._hydration = { state: 'hydrating' };
|
|
786
|
+
const start = Date.now();
|
|
787
|
+
|
|
788
|
+
try {
|
|
789
|
+
const { docsWritten } = await withTimeout(
|
|
790
|
+
this.syncStrategy.hydrate(this.localDB, this.remoteDB),
|
|
791
|
+
HYDRATION_TIMEOUT_MS,
|
|
792
|
+
`Hydration of local mirror for ${this._username}`
|
|
793
|
+
);
|
|
794
|
+
await this.writeHydrationMarker();
|
|
795
|
+
this._hydration = {
|
|
796
|
+
state: 'hydrated',
|
|
797
|
+
docsWritten,
|
|
798
|
+
durationMs: Date.now() - start,
|
|
799
|
+
};
|
|
800
|
+
log(
|
|
801
|
+
`Hydrated local mirror for ${this._username}: ` +
|
|
802
|
+
`${docsWritten} docs in ${this._hydration.durationMs}ms`
|
|
803
|
+
);
|
|
804
|
+
} catch (e) {
|
|
805
|
+
// Cancel the pull if it is merely slow rather than dead — otherwise it
|
|
806
|
+
// races the live sync we are about to start over the same documents.
|
|
807
|
+
this.syncStrategy.stopSync?.();
|
|
808
|
+
this._hydration = {
|
|
809
|
+
state: 'failed',
|
|
810
|
+
durationMs: Date.now() - start,
|
|
811
|
+
error: e instanceof Error ? e.message : String(e),
|
|
812
|
+
};
|
|
813
|
+
logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Has a full pull completed on this device for the CURRENT account?
|
|
819
|
+
*
|
|
820
|
+
* The marker is a `_local/` document, which never replicates — so its
|
|
821
|
+
* presence describes this device's mirror and cannot arrive from elsewhere.
|
|
822
|
+
*/
|
|
823
|
+
private async hasHydrationMarker(): Promise<boolean> {
|
|
824
|
+
try {
|
|
825
|
+
const marker = await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID);
|
|
826
|
+
return marker.username === this._username;
|
|
827
|
+
} catch {
|
|
828
|
+
// Absent, or unreadable — either way, treat as never hydrated. The cost
|
|
829
|
+
// of a false negative is one redundant pull.
|
|
830
|
+
return false;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private async writeHydrationMarker(): Promise<void> {
|
|
835
|
+
try {
|
|
836
|
+
let existingRev: string | undefined;
|
|
837
|
+
try {
|
|
838
|
+
existingRev = (await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID))._rev;
|
|
839
|
+
} catch {
|
|
840
|
+
// First hydration on this device.
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
await this.localDB.put<HydrationMarker>({
|
|
844
|
+
_id: HYDRATION_MARKER_ID,
|
|
845
|
+
_rev: existingRev,
|
|
846
|
+
username: this._username,
|
|
847
|
+
hydratedAt: new Date().toISOString(),
|
|
848
|
+
});
|
|
849
|
+
} catch (e) {
|
|
850
|
+
// Costs a redundant pull on next startup, not correctness.
|
|
851
|
+
logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
664
855
|
private static designDocs: DesignDoc[] = [
|
|
665
856
|
{
|
|
666
857
|
_id: '_design/reviewCards',
|
|
@@ -1083,6 +1274,15 @@ Currently logged-in as ${this._username}.`
|
|
|
1083
1274
|
} catch (e) {
|
|
1084
1275
|
const err = e as PouchError;
|
|
1085
1276
|
if (err.status === 404) {
|
|
1277
|
+
// `null` here means "this user has no such state" — a first-run signal
|
|
1278
|
+
// consumers act on. When the mirror is untrusted we cannot honestly say
|
|
1279
|
+
// that, so fail instead of reporting an established user as new.
|
|
1280
|
+
if (!this.canMaterializeDefaults()) {
|
|
1281
|
+
throw new Error(
|
|
1282
|
+
`Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror ` +
|
|
1283
|
+
`is unavailable (hydration state '${this._hydration.state}').`
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1086
1286
|
return null;
|
|
1087
1287
|
}
|
|
1088
1288
|
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
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
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
|
}
|