@vue-skuilder/db 0.1.31-b → 0.1.31
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/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
- package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
- package/dist/core/index.d.cts +34 -3
- package/dist/core/index.d.ts +34 -3
- package/dist/core/index.js +510 -50
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +510 -50
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +730 -41
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +729 -41
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +467 -31
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +467 -31
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +948 -72
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +948 -72
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +35 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +12 -0
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +3 -1
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import pouch from './pouchdb-setup';
|
|
2
|
+
import { getCourseDB } from '.';
|
|
3
|
+
import { logger } from '../../util/logger';
|
|
4
|
+
import type { CourseConfig } from '@vue-skuilder/common';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// COURSE SYNC SERVICE
|
|
8
|
+
// ============================================================================
|
|
9
|
+
//
|
|
10
|
+
// Manages client-side PouchDB replicas of course databases.
|
|
11
|
+
//
|
|
12
|
+
// Courses opt in to local sync via CourseConfig.localSync.enabled. When
|
|
13
|
+
// enabled, the service performs a one-shot replication from remote CouchDB
|
|
14
|
+
// to a local PouchDB on first visit, then incremental sync on subsequent
|
|
15
|
+
// visits. Pipeline scoring, tag hydration, and card lookup then run against
|
|
16
|
+
// the local replica — eliminating network round trips from the study-session
|
|
17
|
+
// hot path.
|
|
18
|
+
//
|
|
19
|
+
// Read/write split:
|
|
20
|
+
// Local DB = read-only snapshot (pipeline, filters, card lookup)
|
|
21
|
+
// Remote DB = all writes (ELO updates, tag mutations, admin ops)
|
|
22
|
+
//
|
|
23
|
+
// This avoids propagating per-interaction ELO write noise to every syncing
|
|
24
|
+
// client. Each client's local snapshot refreshes on the next page load.
|
|
25
|
+
//
|
|
26
|
+
// Live replication is intentionally NOT supported. The remote course DB
|
|
27
|
+
// receives high-frequency ELO updates from all concurrent users — live
|
|
28
|
+
// sync would cause constant re-indexing of local PouchDB views.
|
|
29
|
+
//
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Sync state for a single course database.
|
|
34
|
+
*/
|
|
35
|
+
export type CourseSyncState =
|
|
36
|
+
| 'not-started'
|
|
37
|
+
| 'checking-config'
|
|
38
|
+
| 'syncing'
|
|
39
|
+
| 'warming-views'
|
|
40
|
+
| 'ready'
|
|
41
|
+
| 'disabled'
|
|
42
|
+
| 'error';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detailed sync status for observability.
|
|
46
|
+
*/
|
|
47
|
+
export interface CourseSyncStatus {
|
|
48
|
+
state: CourseSyncState;
|
|
49
|
+
/** Number of documents replicated (set after sync completes) */
|
|
50
|
+
docsReplicated?: number;
|
|
51
|
+
/** Total replication time in ms */
|
|
52
|
+
syncTimeMs?: number;
|
|
53
|
+
/** View warming time in ms */
|
|
54
|
+
viewWarmTimeMs?: number;
|
|
55
|
+
/** Error message if state is 'error' */
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Internal tracking entry per course.
|
|
61
|
+
*/
|
|
62
|
+
interface SyncEntry {
|
|
63
|
+
localDB: PouchDB.Database | null;
|
|
64
|
+
status: CourseSyncStatus;
|
|
65
|
+
/** Promise that resolves when sync is complete (or rejects on failure) */
|
|
66
|
+
readyPromise: Promise<void> | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Service that manages local PouchDB replicas of course databases.
|
|
71
|
+
*
|
|
72
|
+
* Usage:
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const syncService = CourseSyncService.getInstance();
|
|
75
|
+
*
|
|
76
|
+
* // Trigger sync (typically on app load / pre-session)
|
|
77
|
+
* await syncService.ensureSynced(courseId);
|
|
78
|
+
*
|
|
79
|
+
* // Get local DB for reads (returns null if sync not ready/enabled)
|
|
80
|
+
* const localDB = syncService.getLocalDB(courseId);
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* The service is a singleton — course sync state is shared across the app.
|
|
84
|
+
*/
|
|
85
|
+
export class CourseSyncService {
|
|
86
|
+
private static instance: CourseSyncService | null = null;
|
|
87
|
+
|
|
88
|
+
private entries: Map<string, SyncEntry> = new Map();
|
|
89
|
+
|
|
90
|
+
private constructor() {}
|
|
91
|
+
|
|
92
|
+
static getInstance(): CourseSyncService {
|
|
93
|
+
if (!CourseSyncService.instance) {
|
|
94
|
+
CourseSyncService.instance = new CourseSyncService();
|
|
95
|
+
}
|
|
96
|
+
return CourseSyncService.instance;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reset the singleton (for testing).
|
|
101
|
+
*/
|
|
102
|
+
static resetInstance(): void {
|
|
103
|
+
if (CourseSyncService.instance) {
|
|
104
|
+
// Close all local DBs
|
|
105
|
+
for (const [, entry] of CourseSyncService.instance.entries) {
|
|
106
|
+
if (entry.localDB) {
|
|
107
|
+
entry.localDB.close().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
CourseSyncService.instance.entries.clear();
|
|
111
|
+
}
|
|
112
|
+
CourseSyncService.instance = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --------------------------------------------------------------------------
|
|
116
|
+
// Public API
|
|
117
|
+
// --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ensure a course's local replica is synced.
|
|
121
|
+
*
|
|
122
|
+
* On first call for a course:
|
|
123
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
124
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
125
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
126
|
+
*
|
|
127
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
128
|
+
* the in-flight sync if one is in progress.
|
|
129
|
+
*
|
|
130
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
131
|
+
*
|
|
132
|
+
* @param courseId - The course to sync
|
|
133
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
134
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
135
|
+
* LettersPractice hardcodes this).
|
|
136
|
+
*/
|
|
137
|
+
async ensureSynced(courseId: string, forceEnabled?: boolean): Promise<void> {
|
|
138
|
+
const existing = this.entries.get(courseId);
|
|
139
|
+
|
|
140
|
+
// Already synced
|
|
141
|
+
if (existing?.status.state === 'ready') {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Already disabled
|
|
146
|
+
if (existing?.status.state === 'disabled') {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Sync in flight — coalesce
|
|
151
|
+
if (existing?.readyPromise) {
|
|
152
|
+
return existing.readyPromise;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Start a new sync
|
|
156
|
+
const entry: SyncEntry = {
|
|
157
|
+
localDB: null,
|
|
158
|
+
status: { state: 'not-started' },
|
|
159
|
+
readyPromise: null,
|
|
160
|
+
};
|
|
161
|
+
this.entries.set(courseId, entry);
|
|
162
|
+
|
|
163
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
164
|
+
return entry.readyPromise;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
169
|
+
*
|
|
170
|
+
* Returns null when:
|
|
171
|
+
* - Local sync is not enabled for this course
|
|
172
|
+
* - Sync has not been triggered yet
|
|
173
|
+
* - Sync is still in progress
|
|
174
|
+
* - Sync failed
|
|
175
|
+
*/
|
|
176
|
+
getLocalDB(courseId: string): PouchDB.Database | null {
|
|
177
|
+
const entry = this.entries.get(courseId);
|
|
178
|
+
if (entry?.status.state === 'ready' && entry.localDB) {
|
|
179
|
+
return entry.localDB;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check whether a course has a ready local replica.
|
|
186
|
+
*/
|
|
187
|
+
isReady(courseId: string): boolean {
|
|
188
|
+
return this.entries.get(courseId)?.status.state === 'ready';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get detailed sync status for a course.
|
|
193
|
+
*/
|
|
194
|
+
getStatus(courseId: string): CourseSyncStatus {
|
|
195
|
+
return (
|
|
196
|
+
this.entries.get(courseId)?.status ?? { state: 'not-started' }
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --------------------------------------------------------------------------
|
|
201
|
+
// Internal
|
|
202
|
+
// --------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
private async performSync(
|
|
205
|
+
courseId: string,
|
|
206
|
+
entry: SyncEntry,
|
|
207
|
+
forceEnabled?: boolean
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
try {
|
|
210
|
+
// Step 1: Check if local sync is enabled for this course
|
|
211
|
+
if (!forceEnabled) {
|
|
212
|
+
entry.status = { state: 'checking-config' };
|
|
213
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
214
|
+
if (!enabled) {
|
|
215
|
+
entry.status = { state: 'disabled' };
|
|
216
|
+
entry.readyPromise = null;
|
|
217
|
+
logger.debug(
|
|
218
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Step 2: Create local PouchDB and replicate
|
|
225
|
+
entry.status = { state: 'syncing' };
|
|
226
|
+
const localDBName = this.localDBName(courseId);
|
|
227
|
+
const localDB = new pouch(localDBName);
|
|
228
|
+
entry.localDB = localDB;
|
|
229
|
+
|
|
230
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
231
|
+
const syncStart = Date.now();
|
|
232
|
+
|
|
233
|
+
logger.info(
|
|
234
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
238
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
239
|
+
|
|
240
|
+
logger.info(
|
|
241
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ` +
|
|
242
|
+
`${result.docs_written} docs in ${syncTimeMs}ms`
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
// Step 3: Pre-warm view indices
|
|
246
|
+
entry.status = { state: 'warming-views' };
|
|
247
|
+
const warmStart = Date.now();
|
|
248
|
+
await this.warmViewIndices(localDB);
|
|
249
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
250
|
+
|
|
251
|
+
logger.info(
|
|
252
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Done
|
|
256
|
+
entry.status = {
|
|
257
|
+
state: 'ready',
|
|
258
|
+
docsReplicated: result.docs_written,
|
|
259
|
+
syncTimeMs,
|
|
260
|
+
viewWarmTimeMs,
|
|
261
|
+
};
|
|
262
|
+
} catch (e) {
|
|
263
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
264
|
+
logger.error(
|
|
265
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
266
|
+
);
|
|
267
|
+
entry.status = { state: 'error', error: errorMsg };
|
|
268
|
+
entry.readyPromise = null;
|
|
269
|
+
|
|
270
|
+
// Clean up the local DB on failure — don't leave a partial replica
|
|
271
|
+
if (entry.localDB) {
|
|
272
|
+
try {
|
|
273
|
+
await entry.localDB.destroy();
|
|
274
|
+
} catch {
|
|
275
|
+
// Ignore cleanup errors
|
|
276
|
+
}
|
|
277
|
+
entry.localDB = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
284
|
+
*/
|
|
285
|
+
private async checkLocalSyncEnabled(courseId: string): Promise<boolean> {
|
|
286
|
+
try {
|
|
287
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
288
|
+
const config = await remoteDB.get<CourseConfig>('CourseConfig');
|
|
289
|
+
return config.localSync?.enabled === true;
|
|
290
|
+
} catch (e) {
|
|
291
|
+
logger.warn(
|
|
292
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, ` +
|
|
293
|
+
`assuming local sync disabled: ${e}`
|
|
294
|
+
);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* One-shot replication from remote to local.
|
|
301
|
+
*/
|
|
302
|
+
private replicate(
|
|
303
|
+
source: PouchDB.Database,
|
|
304
|
+
target: PouchDB.Database
|
|
305
|
+
): Promise<PouchDB.Replication.ReplicationResultComplete<object>> {
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
void pouch.replicate(source, target, {
|
|
308
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
309
|
+
})
|
|
310
|
+
.on('complete', (info) => {
|
|
311
|
+
resolve(info);
|
|
312
|
+
})
|
|
313
|
+
.on('error', (err) => {
|
|
314
|
+
reject(err);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
321
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
322
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
323
|
+
*/
|
|
324
|
+
private async warmViewIndices(localDB: PouchDB.Database): Promise<void> {
|
|
325
|
+
const viewsToWarm = ['elo', 'getTags'];
|
|
326
|
+
|
|
327
|
+
for (const viewName of viewsToWarm) {
|
|
328
|
+
try {
|
|
329
|
+
await localDB.query(viewName, { limit: 1 });
|
|
330
|
+
logger.debug(
|
|
331
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
332
|
+
);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
// View might not exist in this course DB — that's OK.
|
|
335
|
+
// Not all courses have all design docs.
|
|
336
|
+
logger.debug(
|
|
337
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get a remote PouchDB handle for a course.
|
|
345
|
+
*/
|
|
346
|
+
private getRemoteDB(courseId: string): PouchDB.Database {
|
|
347
|
+
return getCourseDB(courseId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Local DB naming convention.
|
|
352
|
+
*/
|
|
353
|
+
private localDBName(courseId: string): string {
|
|
354
|
+
return `coursedb-local-${courseId}`;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
@@ -17,6 +17,7 @@ import { getLoggedInUsername } from './auth';
|
|
|
17
17
|
import { AdminDB } from './adminDB';
|
|
18
18
|
import { StudentClassroomDB, TeacherClassroomDB } from './classroomDB';
|
|
19
19
|
import { CourseDB, CoursesDB } from './courseDB';
|
|
20
|
+
import { CourseSyncService } from './CourseSyncService';
|
|
20
21
|
|
|
21
22
|
import { BaseUser } from '../common';
|
|
22
23
|
import { CouchDBSyncStrategy } from './CouchDBSyncStrategy';
|
|
@@ -73,7 +74,26 @@ export class CouchDataLayerProvider implements DataLayerProvider {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
getCourseDB(courseId: string): CourseDBInterface {
|
|
76
|
-
|
|
77
|
+
// If the CourseSyncService has a ready local replica for this course,
|
|
78
|
+
// pass it to CourseDB so reads (pipeline, card hydration) run locally.
|
|
79
|
+
// Writes always go to the remote DB (handled inside CourseDB).
|
|
80
|
+
const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
|
|
81
|
+
return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? undefined);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Trigger local sync for a course. Call during app initialization or
|
|
86
|
+
* pre-session loading for courses that opt in via CourseConfig.localSync.
|
|
87
|
+
*
|
|
88
|
+
* Safe to call multiple times — concurrent calls coalesce. Returns when
|
|
89
|
+
* sync is complete (or immediately if already synced / disabled).
|
|
90
|
+
*
|
|
91
|
+
* @param courseId - The course to sync locally
|
|
92
|
+
* @param forceEnabled - Skip CourseConfig check and sync regardless.
|
|
93
|
+
* Use when the caller already knows local sync is desired.
|
|
94
|
+
*/
|
|
95
|
+
async ensureCourseSynced(courseId: string, forceEnabled?: boolean): Promise<void> {
|
|
96
|
+
return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
getCoursesDB(): CoursesDBInterface {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
toCourseElo,
|
|
10
10
|
} from '@vue-skuilder/common';
|
|
11
11
|
|
|
12
|
-
import { filterAllDocsByPrefix, getCourseDB
|
|
12
|
+
import { filterAllDocsByPrefix, getCourseDB } from '.';
|
|
13
13
|
import UpdateQueue from './updateQueue';
|
|
14
14
|
import { StudySessionItem } from '../../core/interfaces/contentSource';
|
|
15
15
|
import {
|
|
@@ -94,16 +94,48 @@ export class CourseDB implements CourseDBInterface {
|
|
|
94
94
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
95
95
|
// }
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
99
|
+
*
|
|
100
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
101
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
102
|
+
*/
|
|
97
103
|
private db: PouchDB.Database;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remote database handle used for all **write** operations.
|
|
107
|
+
*
|
|
108
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
109
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
110
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
111
|
+
*
|
|
112
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
113
|
+
*/
|
|
114
|
+
private remoteDB: PouchDB.Database;
|
|
115
|
+
|
|
98
116
|
private id: string;
|
|
99
117
|
private _getCurrentUser: () => Promise<UserDBInterface>;
|
|
100
118
|
private updateQueue: UpdateQueue;
|
|
101
119
|
|
|
102
|
-
|
|
120
|
+
/**
|
|
121
|
+
* @param id - Course ID
|
|
122
|
+
* @param userLookup - Async function returning the current user DB
|
|
123
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
124
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
125
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
126
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
127
|
+
* the remote DB to avoid conflicts).
|
|
128
|
+
*/
|
|
129
|
+
constructor(id: string, userLookup: () => Promise<UserDBInterface>, localDB?: PouchDB.Database) {
|
|
103
130
|
this.id = id;
|
|
104
|
-
|
|
131
|
+
const remote = getCourseDB(this.id);
|
|
132
|
+
this.remoteDB = remote;
|
|
133
|
+
this.db = localDB ?? remote;
|
|
105
134
|
this._getCurrentUser = userLookup;
|
|
106
|
-
|
|
135
|
+
// UpdateQueue always operates against the remote DB for its
|
|
136
|
+
// read-modify-write cycle. Local _rev values may be stale (the local
|
|
137
|
+
// replica is a snapshot), so conflict retries must read from remote.
|
|
138
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
107
139
|
}
|
|
108
140
|
|
|
109
141
|
public getCourseID(): string {
|
|
@@ -217,7 +249,9 @@ export class CourseDB implements CourseDBInterface {
|
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
public async removeCard(id: string) {
|
|
220
|
-
|
|
252
|
+
// Admin operation — read and write both go through remote DB to ensure
|
|
253
|
+
// we have the current _rev for the delete.
|
|
254
|
+
const doc = await this.remoteDB.get<CardData>(id);
|
|
221
255
|
if (!doc.docType || !(doc.docType === DocType.CARD)) {
|
|
222
256
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
223
257
|
}
|
|
@@ -244,7 +278,7 @@ export class CourseDB implements CourseDBInterface {
|
|
|
244
278
|
// Continue with card deletion even if tag cleanup fails
|
|
245
279
|
}
|
|
246
280
|
|
|
247
|
-
return this.
|
|
281
|
+
return this.remoteDB.remove(doc);
|
|
248
282
|
}
|
|
249
283
|
|
|
250
284
|
public async getCardDisplayableDataIDs(id: string[]) {
|
|
@@ -364,8 +398,7 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
364
398
|
return new Map();
|
|
365
399
|
}
|
|
366
400
|
|
|
367
|
-
const
|
|
368
|
-
const result = await db.query<TagStub>('getTags', {
|
|
401
|
+
const result = await this.db.query<TagStub>('getTags', {
|
|
369
402
|
keys: cardIds,
|
|
370
403
|
include_docs: false,
|
|
371
404
|
});
|
|
@@ -389,6 +422,15 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
389
422
|
return tagsByCard;
|
|
390
423
|
}
|
|
391
424
|
|
|
425
|
+
async getAllCardIds(): Promise<string[]> {
|
|
426
|
+
const result = await this.db.allDocs({
|
|
427
|
+
startkey: 'CARD-',
|
|
428
|
+
endkey: 'CARD-\ufff0',
|
|
429
|
+
include_docs: false,
|
|
430
|
+
});
|
|
431
|
+
return result.rows.map((row) => row.id);
|
|
432
|
+
}
|
|
433
|
+
|
|
392
434
|
async addTagToCard(
|
|
393
435
|
cardId: string,
|
|
394
436
|
tagId: string,
|
|
@@ -479,14 +521,20 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
479
521
|
id: string,
|
|
480
522
|
options?: PouchDB.Core.GetOptions
|
|
481
523
|
): Promise<PouchDB.Core.GetMeta & PouchDB.Core.Document<T>> {
|
|
482
|
-
|
|
524
|
+
// Use this.db (local when available) for read operations.
|
|
525
|
+
// Falls back to the standalone helper (always remote) only if needed.
|
|
526
|
+
return await this.db.get<T>(id, options) as PouchDB.Core.GetMeta & PouchDB.Core.Document<T>;
|
|
483
527
|
}
|
|
484
528
|
|
|
485
529
|
async getCourseDocs<T extends SkuilderCourseData>(
|
|
486
530
|
ids: string[],
|
|
487
531
|
options: PouchDB.Core.AllDocsOptions = {}
|
|
488
532
|
): Promise<PouchDB.Core.AllDocsWithKeysResponse<{} & T>> {
|
|
489
|
-
|
|
533
|
+
// Use this.db (local when available) for read operations.
|
|
534
|
+
return await this.db.allDocs<T>({
|
|
535
|
+
...options,
|
|
536
|
+
keys: ids,
|
|
537
|
+
}) as PouchDB.Core.AllDocsWithKeysResponse<{} & T>;
|
|
490
538
|
}
|
|
491
539
|
|
|
492
540
|
////////////////////////////////////
|
|
@@ -524,9 +572,8 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
524
572
|
|
|
525
573
|
async addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
|
|
526
574
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
return this.db.put(data).then(() => {});
|
|
575
|
+
// Admin write operation — use remote DB.
|
|
576
|
+
return this.remoteDB.put(data).then(() => {});
|
|
530
577
|
}
|
|
531
578
|
updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise<void> {
|
|
532
579
|
logger.debug(`[courseDB] Updating navigation strategy: ${id}`);
|
package/src/impl/couch/index.ts
CHANGED
|
@@ -244,6 +244,11 @@ export class StaticCourseDB implements CourseDBInterface {
|
|
|
244
244
|
return tagsByCard;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
async getAllCardIds(): Promise<string[]> {
|
|
248
|
+
const tagsIndex = await this.unpacker.getTagsIndex();
|
|
249
|
+
return Object.keys(tagsIndex.byCard);
|
|
250
|
+
}
|
|
251
|
+
|
|
247
252
|
async addTagToCard(_cardId: string, _tagId: string): Promise<PouchDB.Core.Response> {
|
|
248
253
|
throw new Error('Cannot modify tags in static mode');
|
|
249
254
|
}
|
package/src/study/ItemQueue.ts
CHANGED
|
@@ -47,6 +47,48 @@ export class ItemQueue<T> {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Atomically replace all queue contents with new items.
|
|
52
|
+
*
|
|
53
|
+
* Used by mid-session replanning to swap the queue without a window where
|
|
54
|
+
* it's empty (avoiding dead-air if nextCard() is called concurrently).
|
|
55
|
+
*
|
|
56
|
+
* Preserves dequeueCount (cumulative across the session).
|
|
57
|
+
* Resets seenCardIds to match the new contents — cards from the old queue
|
|
58
|
+
* that don't appear in the new set can be re-added in future replans.
|
|
59
|
+
*/
|
|
60
|
+
public replaceAll(items: T[], cardIdExtractor: (item: T) => string): void {
|
|
61
|
+
this.q = [];
|
|
62
|
+
this.seenCardIds = [];
|
|
63
|
+
for (const item of items) {
|
|
64
|
+
const cardId = cardIdExtractor(item);
|
|
65
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
66
|
+
this.seenCardIds.push(cardId);
|
|
67
|
+
this.q.push(item);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merge new items into the front of the queue, skipping duplicates.
|
|
74
|
+
* Used by additive replans to inject high-quality candidates without
|
|
75
|
+
* discarding the existing queue contents.
|
|
76
|
+
*/
|
|
77
|
+
public mergeToFront(items: T[], cardIdExtractor: (item: T) => string): number {
|
|
78
|
+
let added = 0;
|
|
79
|
+
const toInsert: T[] = [];
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const cardId = cardIdExtractor(item);
|
|
82
|
+
if (!this.seenCardIds.includes(cardId)) {
|
|
83
|
+
this.seenCardIds.push(cardId);
|
|
84
|
+
toInsert.push(item);
|
|
85
|
+
added++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
this.q.unshift(...toInsert);
|
|
89
|
+
return added;
|
|
90
|
+
}
|
|
91
|
+
|
|
50
92
|
public get toString(): string {
|
|
51
93
|
return (
|
|
52
94
|
`${typeof this.q[0]}:\n` +
|