@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.
- 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-B1p-vdz7.d.ts → contentSource-BDRX_YYJ.d.ts} +97 -1
- package/dist/{contentSource-Brz42x7n.d.cts → contentSource-D-LQYFyx.d.cts} +97 -1
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +167 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +166 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-Dd2UcCif.d.cts} +1 -1
- package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-Dpshuql4.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 +199 -3
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +199 -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 +165 -1
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +165 -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 +201 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +200 -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 +24 -0
- package/src/core/types/hydration.ts +78 -0
- package/src/impl/common/BaseUserDB.ts +233 -2
- package/src/impl/common/SyncStrategy.ts +19 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
- 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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
//
|
|
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
|
}
|