@vue-skuilder/db 0.1.11-9 → 0.1.12
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/core/index.d.mts +7 -6
- package/dist/core/index.d.ts +7 -6
- package/dist/core/index.js +358 -87
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +358 -87
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DqtNroSh.d.ts → dataLayerProvider-BiP3kWix.d.mts} +8 -1
- package/dist/{dataLayerProvider-BInqI_RF.d.mts → dataLayerProvider-DSdeyRT3.d.ts} +8 -1
- package/dist/impl/couch/index.d.mts +19 -7
- package/dist/impl/couch/index.d.ts +19 -7
- package/dist/impl/couch/index.js +375 -100
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +374 -99
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.mts +23 -8
- package/dist/impl/static/index.d.ts +23 -8
- package/dist/impl/static/index.js +289 -85
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +289 -85
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-CUNnL38E.d.mts → index-Bmll7Xse.d.mts} +1 -1
- package/dist/{index-CLL31bEy.d.ts → index-CD8BZz2k.d.ts} +1 -1
- package/dist/index.d.mts +123 -20
- package/dist/index.d.ts +123 -20
- package/dist/index.js +1133 -343
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1137 -343
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.d.mts +1 -0
- package/dist/pouch/index.d.ts +1 -0
- package/dist/pouch/index.js +49 -0
- package/dist/pouch/index.js.map +1 -0
- package/dist/pouch/index.mjs +16 -0
- package/dist/pouch/index.mjs.map +1 -0
- package/dist/{types-BefDGkKa.d.ts → types-CewsN87z.d.ts} +1 -1
- package/dist/{types-DC-ckZug.d.mts → types-Dbp5DaRR.d.mts} +1 -1
- package/dist/{types-legacy-Birv-Jx6.d.mts → types-legacy-6ettoclI.d.mts} +17 -2
- package/dist/{types-legacy-Birv-Jx6.d.ts → types-legacy-6ettoclI.d.ts} +17 -2
- package/dist/{userDB-DusL7OXe.d.ts → userDB-C4yyAnpp.d.mts} +89 -56
- package/dist/{userDB-C33Hzjgn.d.mts → userDB-CD6s6ZCp.d.ts} +89 -56
- package/dist/util/packer/index.d.mts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +3 -2
- package/src/core/interfaces/courseDB.ts +26 -3
- package/src/core/interfaces/dataLayerProvider.ts +9 -1
- package/src/core/interfaces/userDB.ts +80 -64
- package/src/core/navigators/elo.ts +10 -7
- package/src/core/navigators/hardcodedOrder.ts +64 -0
- package/src/core/navigators/index.ts +2 -1
- package/src/core/types/contentNavigationStrategy.ts +2 -1
- package/src/core/types/types-legacy.ts +7 -2
- package/src/impl/common/BaseUserDB.ts +60 -14
- package/src/impl/couch/CouchDBSyncStrategy.ts +2 -2
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -0
- package/src/impl/couch/adminDB.ts +2 -2
- package/src/impl/couch/auth.ts +13 -4
- package/src/impl/couch/classroomDB.ts +10 -12
- package/src/impl/couch/courseAPI.ts +2 -2
- package/src/impl/couch/courseDB.ts +204 -38
- package/src/impl/couch/courseLookupDB.ts +4 -3
- package/src/impl/couch/index.ts +36 -4
- package/src/impl/couch/pouchdb-setup.ts +3 -3
- package/src/impl/couch/updateQueue.ts +59 -36
- package/src/impl/static/StaticDataLayerProvider.ts +68 -17
- package/src/impl/static/courseDB.ts +64 -20
- package/src/impl/static/coursesDB.ts +10 -6
- package/src/pouch/index.ts +2 -0
- package/src/study/ItemQueue.ts +58 -0
- package/src/study/SessionController.ts +182 -111
- package/src/study/SpacedRepetition.ts +1 -1
- package/src/study/services/CardHydrationService.ts +153 -0
- package/src/study/services/EloService.ts +85 -0
- package/src/study/services/ResponseProcessor.ts +224 -0
- package/src/study/services/SrsService.ts +44 -0
- package/tsup.config.ts +1 -0
package/src/impl/couch/auth.ts
CHANGED
|
@@ -39,10 +39,14 @@ export async function getCurrentSession(): Promise<SessionResponse> {
|
|
|
39
39
|
try {
|
|
40
40
|
// Handle case where ENV variables might not be properly set
|
|
41
41
|
if (ENV.COUCHDB_SERVER_URL === NOT_SET || ENV.COUCHDB_SERVER_PROTOCOL === NOT_SET) {
|
|
42
|
-
throw new Error(
|
|
42
|
+
throw new Error(`CouchDB server configuration not properly initialized. Protocol: "${ENV.COUCHDB_SERVER_PROTOCOL}", URL: "${ENV.COUCHDB_SERVER_URL}"`);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// Ensure URL has proper slash before _session endpoint
|
|
46
|
+
const baseUrl = ENV.COUCHDB_SERVER_URL.endsWith('/')
|
|
47
|
+
? ENV.COUCHDB_SERVER_URL.slice(0, -1)
|
|
48
|
+
: ENV.COUCHDB_SERVER_URL;
|
|
49
|
+
const url = `${ENV.COUCHDB_SERVER_PROTOCOL}://${baseUrl}/_session`;
|
|
46
50
|
logger.debug(`Attempting session check at: ${url}`);
|
|
47
51
|
|
|
48
52
|
const response = await fetch(url, {
|
|
@@ -57,8 +61,13 @@ export async function getCurrentSession(): Promise<SessionResponse> {
|
|
|
57
61
|
const resp: SessionResponse = await response.json();
|
|
58
62
|
return resp;
|
|
59
63
|
} catch (error) {
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
// Use same URL construction logic for error reporting
|
|
65
|
+
const baseUrl = ENV.COUCHDB_SERVER_URL.endsWith('/')
|
|
66
|
+
? ENV.COUCHDB_SERVER_URL.slice(0, -1)
|
|
67
|
+
: ENV.COUCHDB_SERVER_URL;
|
|
68
|
+
const url = `${ENV.COUCHDB_SERVER_PROTOCOL}://${baseUrl}/_session`;
|
|
69
|
+
logger.error(`Session check error attempting to connect to: ${url} - ${error}`);
|
|
70
|
+
throw new Error(`Session check failed connecting to ${url}: ${error}`);
|
|
62
71
|
}
|
|
63
72
|
}
|
|
64
73
|
|
|
@@ -8,12 +8,7 @@ import { ENV } from '@db/factory';
|
|
|
8
8
|
import { logger } from '@db/util/logger';
|
|
9
9
|
import moment from 'moment';
|
|
10
10
|
import pouch from './pouchdb-setup';
|
|
11
|
-
import {
|
|
12
|
-
getCourseDB,
|
|
13
|
-
getStartAndEndKeys,
|
|
14
|
-
pouchDBincludeCredentialsConfig,
|
|
15
|
-
REVIEW_TIME_FORMAT,
|
|
16
|
-
} from '.';
|
|
11
|
+
import { getCourseDB, getStartAndEndKeys, createPouchDBConfig, REVIEW_TIME_FORMAT } from '.';
|
|
17
12
|
import { CourseDB, getTag } from './courseDB';
|
|
18
13
|
|
|
19
14
|
import { UserDBInterface } from '@db/core';
|
|
@@ -97,7 +92,7 @@ export class StudentClassroomDB
|
|
|
97
92
|
const dbName = `classdb-student-${this._id}`;
|
|
98
93
|
this._db = new pouch(
|
|
99
94
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
100
|
-
|
|
95
|
+
createPouchDBConfig()
|
|
101
96
|
);
|
|
102
97
|
try {
|
|
103
98
|
const cfg = await this._db.get<ClassroomConfig>(CLASSROOM_CONFIG);
|
|
@@ -181,10 +176,13 @@ export class StudentClassroomDB
|
|
|
181
176
|
}
|
|
182
177
|
}
|
|
183
178
|
|
|
184
|
-
logger.info(
|
|
179
|
+
logger.info(
|
|
180
|
+
`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => `${c.courseID}-${c.cardID}`)}`
|
|
181
|
+
);
|
|
185
182
|
|
|
186
183
|
return ret.filter((c) => {
|
|
187
|
-
if (activeCards.some((ac) => c.
|
|
184
|
+
if (activeCards.some((ac) => c.cardID === ac.cardID)) {
|
|
185
|
+
// [ ] almost certainly broken after removing qualifiedID from StudySessionItem
|
|
188
186
|
return false;
|
|
189
187
|
} else {
|
|
190
188
|
return true;
|
|
@@ -209,11 +207,11 @@ export class TeacherClassroomDB extends ClassroomDBBase implements TeacherClassr
|
|
|
209
207
|
const stuDbName = `classdb-student-${this._id}`;
|
|
210
208
|
this._db = new pouch(
|
|
211
209
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
212
|
-
|
|
210
|
+
createPouchDBConfig()
|
|
213
211
|
);
|
|
214
212
|
this._stuDb = new pouch(
|
|
215
213
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + stuDbName,
|
|
216
|
-
|
|
214
|
+
createPouchDBConfig()
|
|
217
215
|
);
|
|
218
216
|
try {
|
|
219
217
|
return this._db
|
|
@@ -297,7 +295,7 @@ export function getClassroomDB(classID: string, version: 'student' | 'teacher'):
|
|
|
297
295
|
|
|
298
296
|
return new pouch(
|
|
299
297
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
300
|
-
|
|
298
|
+
createPouchDBConfig()
|
|
301
299
|
);
|
|
302
300
|
}
|
|
303
301
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pouch from './pouchdb-setup';
|
|
2
|
-
import {
|
|
2
|
+
import { createPouchDBConfig } from '.';
|
|
3
3
|
import { ENV } from '@db/factory';
|
|
4
4
|
// import { DataShape } from '../..base-course/Interfaces/DataShape';
|
|
5
5
|
import { NameSpacer, ShapeDescriptor } from '@vue-skuilder/common';
|
|
@@ -279,6 +279,6 @@ export function getCourseDB(courseID: string): PouchDB.Database {
|
|
|
279
279
|
const dbName = `coursedb-${courseID}`;
|
|
280
280
|
return new pouch(
|
|
281
281
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
282
|
-
|
|
282
|
+
createPouchDBConfig()
|
|
283
283
|
);
|
|
284
284
|
}
|
|
@@ -18,7 +18,15 @@ import {
|
|
|
18
18
|
StudySessionNewItem,
|
|
19
19
|
StudySessionReviewItem,
|
|
20
20
|
} from '../../core/interfaces/contentSource';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
CardData,
|
|
23
|
+
DocType,
|
|
24
|
+
QualifiedCardID,
|
|
25
|
+
SkuilderCourseData,
|
|
26
|
+
Tag,
|
|
27
|
+
TagStub,
|
|
28
|
+
DocTypePrefixes,
|
|
29
|
+
} from '../../core/types/types-legacy';
|
|
22
30
|
import { logger } from '../../util/logger';
|
|
23
31
|
import { GET_CACHED } from './clientCache';
|
|
24
32
|
import { addNote55, addTagToCard, getCredentialledCourseConfig, getTagID } from './courseAPI';
|
|
@@ -217,7 +225,29 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
217
225
|
if (!doc.docType || !(doc.docType === DocType.CARD)) {
|
|
218
226
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
219
227
|
}
|
|
220
|
-
|
|
228
|
+
|
|
229
|
+
// Remove card from all associated tags before deleting the card
|
|
230
|
+
try {
|
|
231
|
+
const appliedTags = await this.getAppliedTags(id);
|
|
232
|
+
const results = await Promise.allSettled(
|
|
233
|
+
appliedTags.rows.map(async (tagRow) => {
|
|
234
|
+
const tagId = tagRow.id;
|
|
235
|
+
await this.removeTagFromCard(id, tagId);
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Log any individual tag cleanup failures
|
|
240
|
+
results.forEach((result, index) => {
|
|
241
|
+
if (result.status === 'rejected') {
|
|
242
|
+
const tagId = appliedTags.rows[index].id;
|
|
243
|
+
logger.error(`Failed to remove card ${id} from tag ${tagId}: ${result.reason}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
248
|
+
// Continue with card deletion even if tag cleanup fails
|
|
249
|
+
}
|
|
250
|
+
|
|
221
251
|
return this.db.remove(doc);
|
|
222
252
|
}
|
|
223
253
|
|
|
@@ -263,7 +293,7 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
263
293
|
limit: aboveLimit,
|
|
264
294
|
startkey: elo + 1,
|
|
265
295
|
});
|
|
266
|
-
//
|
|
296
|
+
// logger.log(JSON.stringify(below));
|
|
267
297
|
|
|
268
298
|
let cards = below.rows;
|
|
269
299
|
cards = cards.concat(above.rows);
|
|
@@ -277,7 +307,13 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
|
|
|
277
307
|
return s;
|
|
278
308
|
}
|
|
279
309
|
})
|
|
280
|
-
.map((c) =>
|
|
310
|
+
.map((c) => {
|
|
311
|
+
return {
|
|
312
|
+
courseID: this.id,
|
|
313
|
+
cardID: c.id,
|
|
314
|
+
elo: c.key,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
281
317
|
|
|
282
318
|
const str = `below:\n${below.rows.map((r) => `\t${r.id}-${r.key}\n`)}
|
|
283
319
|
|
|
@@ -342,7 +378,13 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
342
378
|
tagId: string,
|
|
343
379
|
updateELO?: boolean
|
|
344
380
|
): Promise<PouchDB.Core.Response> {
|
|
345
|
-
return await addTagToCard(
|
|
381
|
+
return await addTagToCard(
|
|
382
|
+
this.id,
|
|
383
|
+
cardId,
|
|
384
|
+
tagId,
|
|
385
|
+
(await this._getCurrentUser()).getUsername(),
|
|
386
|
+
updateELO
|
|
387
|
+
);
|
|
346
388
|
}
|
|
347
389
|
|
|
348
390
|
async removeTagFromCard(cardId: string, tagId: string): Promise<PouchDB.Core.Response> {
|
|
@@ -437,40 +479,38 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
437
479
|
|
|
438
480
|
getNavigationStrategy(id: string): Promise<ContentNavigationStrategyData> {
|
|
439
481
|
logger.debug(`[courseDB] Getting navigation strategy: ${id}`);
|
|
440
|
-
// For now, just return the ELO strategy regardless of the ID
|
|
441
|
-
const strategy: ContentNavigationStrategyData = {
|
|
442
|
-
id: 'ELO',
|
|
443
|
-
docType: DocType.NAVIGATION_STRATEGY,
|
|
444
|
-
name: 'ELO',
|
|
445
|
-
description: 'ELO-based navigation strategy for ordering content by difficulty',
|
|
446
|
-
implementingClass: Navigators.ELO,
|
|
447
|
-
course: this.id,
|
|
448
|
-
serializedData: '', // serde is a noop for ELO navigator.
|
|
449
|
-
};
|
|
450
|
-
return Promise.resolve(strategy);
|
|
451
|
-
}
|
|
452
482
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
{
|
|
457
|
-
id: 'ELO',
|
|
483
|
+
if (id == '') {
|
|
484
|
+
const strategy: ContentNavigationStrategyData = {
|
|
485
|
+
_id: 'NAVIGATION_STRATEGY-ELO',
|
|
458
486
|
docType: DocType.NAVIGATION_STRATEGY,
|
|
459
487
|
name: 'ELO',
|
|
460
488
|
description: 'ELO-based navigation strategy for ordering content by difficulty',
|
|
461
489
|
implementingClass: Navigators.ELO,
|
|
462
490
|
course: this.id,
|
|
463
491
|
serializedData: '', // serde is a noop for ELO navigator.
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
492
|
+
};
|
|
493
|
+
return Promise.resolve(strategy);
|
|
494
|
+
} else {
|
|
495
|
+
return this.db.get(id);
|
|
496
|
+
}
|
|
467
497
|
}
|
|
468
498
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
499
|
+
async getAllNavigationStrategies(): Promise<ContentNavigationStrategyData[]> {
|
|
500
|
+
const prefix = DocTypePrefixes[DocType.NAVIGATION_STRATEGY];
|
|
501
|
+
const result = await this.db.allDocs<ContentNavigationStrategyData>({
|
|
502
|
+
startkey: prefix,
|
|
503
|
+
endkey: `${prefix}\ufff0`,
|
|
504
|
+
include_docs: true,
|
|
505
|
+
});
|
|
506
|
+
return result.rows.map((row) => row.doc!);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
|
|
510
|
+
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
511
|
+
// // For now, just log the data and return success
|
|
512
|
+
// logger.debug(JSON.stringify(data));
|
|
513
|
+
return this.db.put(data).then(() => {});
|
|
474
514
|
}
|
|
475
515
|
updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise<void> {
|
|
476
516
|
logger.debug(`[courseDB] Updating navigation strategy: ${id}`);
|
|
@@ -480,9 +520,35 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
480
520
|
}
|
|
481
521
|
|
|
482
522
|
async surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData> {
|
|
523
|
+
try {
|
|
524
|
+
const config = await this.getCourseConfig();
|
|
525
|
+
// @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
|
|
526
|
+
if (config.defaultNavigationStrategyId) {
|
|
527
|
+
try {
|
|
528
|
+
// @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
|
|
529
|
+
const strategy = await this.getNavigationStrategy(config.defaultNavigationStrategyId);
|
|
530
|
+
if (strategy) {
|
|
531
|
+
logger.debug(`Surfacing strategy ${strategy.name} from course config`);
|
|
532
|
+
return strategy;
|
|
533
|
+
}
|
|
534
|
+
} catch (e) {
|
|
535
|
+
logger.warn(
|
|
536
|
+
// @ts-expect-error tmp: defaultNavigationStrategyId property does not yet exist
|
|
537
|
+
`Failed to load strategy '${config.defaultNavigationStrategyId}' specified in course config. Falling back to ELO.`,
|
|
538
|
+
e
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch (e) {
|
|
543
|
+
logger.warn(
|
|
544
|
+
'Could not retrieve course config to determine navigation strategy. Falling back to ELO.',
|
|
545
|
+
e
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
483
549
|
logger.warn(`Returning hard-coded default ELO navigator`);
|
|
484
550
|
const ret: ContentNavigationStrategyData = {
|
|
485
|
-
|
|
551
|
+
_id: 'NAVIGATION_STRATEGY-ELO',
|
|
486
552
|
docType: DocType.NAVIGATION_STRATEGY,
|
|
487
553
|
name: 'ELO',
|
|
488
554
|
description: 'ELO-based navigation strategy',
|
|
@@ -535,7 +601,7 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
535
601
|
limit: 99,
|
|
536
602
|
elo: 'user',
|
|
537
603
|
},
|
|
538
|
-
filter?: (a:
|
|
604
|
+
filter?: (a: QualifiedCardID) => boolean
|
|
539
605
|
): Promise<StudySessionItem[]> {
|
|
540
606
|
let targetElo: number;
|
|
541
607
|
|
|
@@ -554,12 +620,12 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
554
620
|
} else if (options.elo === 'random') {
|
|
555
621
|
const bounds = await GET_CACHED(`elo-bounds-${this.id}`, () => this.getELOBounds());
|
|
556
622
|
targetElo = Math.round(bounds.low + Math.random() * (bounds.high - bounds.low));
|
|
557
|
-
//
|
|
623
|
+
// logger.log(`Picked ${targetElo} from [${bounds.low}, ${bounds.high}]`);
|
|
558
624
|
} else {
|
|
559
625
|
targetElo = options.elo;
|
|
560
626
|
}
|
|
561
627
|
|
|
562
|
-
let cards:
|
|
628
|
+
let cards: (QualifiedCardID & { elo?: number })[] = [];
|
|
563
629
|
let mult: number = 4;
|
|
564
630
|
let previousCount: number = -1;
|
|
565
631
|
let newCount: number = 0;
|
|
@@ -579,7 +645,11 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
579
645
|
mult *= 2;
|
|
580
646
|
}
|
|
581
647
|
|
|
582
|
-
const selectedCards:
|
|
648
|
+
const selectedCards: {
|
|
649
|
+
courseID: string;
|
|
650
|
+
cardID: string;
|
|
651
|
+
elo?: number;
|
|
652
|
+
}[] = [];
|
|
583
653
|
|
|
584
654
|
while (selectedCards.length < options.limit && cards.length > 0) {
|
|
585
655
|
const index = randIntWeightedTowardZero(cards.length);
|
|
@@ -588,17 +658,113 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
|
|
|
588
658
|
}
|
|
589
659
|
|
|
590
660
|
return selectedCards.map((c) => {
|
|
591
|
-
const split = c.split('-');
|
|
592
661
|
return {
|
|
593
662
|
courseID: this.id,
|
|
594
|
-
cardID:
|
|
595
|
-
qualifiedID: `${split[0]}-${split[1]}`,
|
|
663
|
+
cardID: c.cardID,
|
|
596
664
|
contentSourceType: 'course',
|
|
597
665
|
contentSourceID: this.id,
|
|
666
|
+
elo: c.elo,
|
|
598
667
|
status: 'new',
|
|
599
668
|
};
|
|
600
669
|
});
|
|
601
670
|
}
|
|
671
|
+
|
|
672
|
+
// Admin search methods
|
|
673
|
+
public async searchCards(query: string): Promise<any[]> {
|
|
674
|
+
logger.log(`[CourseDB ${this.id}] Searching for: "${query}"`);
|
|
675
|
+
|
|
676
|
+
// Try multiple search approaches
|
|
677
|
+
let displayableData;
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
// Try regex search on the correct data structure: data[0].data
|
|
681
|
+
displayableData = await this.db.find({
|
|
682
|
+
selector: {
|
|
683
|
+
docType: 'DISPLAYABLE_DATA',
|
|
684
|
+
'data.0.data': { $regex: `.*${query}.*` },
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
logger.log(`[CourseDB ${this.id}] Regex search on data[0].data successful`);
|
|
688
|
+
} catch (regexError) {
|
|
689
|
+
logger.log(
|
|
690
|
+
`[CourseDB ${this.id}] Regex search failed, falling back to manual search:`,
|
|
691
|
+
regexError
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
// Fallback: get all displayable data and filter manually
|
|
695
|
+
const allDisplayable = await this.db.find({
|
|
696
|
+
selector: {
|
|
697
|
+
docType: 'DISPLAYABLE_DATA',
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
logger.log(
|
|
702
|
+
`[CourseDB ${this.id}] Retrieved ${allDisplayable.docs.length} documents for manual filtering`
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
displayableData = {
|
|
706
|
+
docs: allDisplayable.docs.filter((doc) => {
|
|
707
|
+
// Search entire document as JSON string - inclusive approach for admin tool
|
|
708
|
+
const docString = JSON.stringify(doc).toLowerCase();
|
|
709
|
+
const match = docString.includes(query.toLowerCase());
|
|
710
|
+
if (match) {
|
|
711
|
+
logger.log(`[CourseDB ${this.id}] Manual match found in document: ${doc._id}`);
|
|
712
|
+
}
|
|
713
|
+
return match;
|
|
714
|
+
}),
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
logger.log(
|
|
719
|
+
`[CourseDB ${this.id}] Found ${displayableData.docs.length} displayable data documents`
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (displayableData.docs.length === 0) {
|
|
723
|
+
// Debug: Let's see what displayable data exists
|
|
724
|
+
const allDisplayableData = await this.db.find({
|
|
725
|
+
selector: {
|
|
726
|
+
docType: 'DISPLAYABLE_DATA',
|
|
727
|
+
},
|
|
728
|
+
limit: 5, // Just sample a few
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
logger.log(
|
|
732
|
+
`[CourseDB ${this.id}] Sample displayable data:`,
|
|
733
|
+
allDisplayableData.docs.map((d) => ({
|
|
734
|
+
id: d._id,
|
|
735
|
+
docType: (d as any).docType,
|
|
736
|
+
dataStructure: (d as any).data ? Object.keys((d as any).data) : 'no data field',
|
|
737
|
+
dataContent: (d as any).data,
|
|
738
|
+
fullDoc: d,
|
|
739
|
+
}))
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const allResults: any[] = [];
|
|
744
|
+
|
|
745
|
+
for (const dd of displayableData.docs) {
|
|
746
|
+
const cards = await this.db.find({
|
|
747
|
+
selector: {
|
|
748
|
+
docType: 'CARD',
|
|
749
|
+
id_displayable_data: { $in: [dd._id] },
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
logger.log(
|
|
754
|
+
`[CourseDB ${this.id}] Displayable data ${dd._id} linked to ${cards.docs.length} cards`
|
|
755
|
+
);
|
|
756
|
+
allResults.push(...cards.docs);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
logger.log(`[CourseDB ${this.id}] Total cards found: ${allResults.length}`);
|
|
760
|
+
return allResults;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
public async find(
|
|
764
|
+
request: PouchDB.Find.FindRequest<any>
|
|
765
|
+
): Promise<PouchDB.Find.FindResponse<any>> {
|
|
766
|
+
return this.db.find(request);
|
|
767
|
+
}
|
|
602
768
|
}
|
|
603
769
|
|
|
604
770
|
/**
|
|
@@ -105,15 +105,15 @@ export default class CourseLookup {
|
|
|
105
105
|
* @returns Promise<void>
|
|
106
106
|
*/
|
|
107
107
|
static async addWithId(
|
|
108
|
-
courseId: string,
|
|
109
|
-
courseName: string,
|
|
108
|
+
courseId: string,
|
|
109
|
+
courseName: string,
|
|
110
110
|
disambiguator?: string
|
|
111
111
|
): Promise<void> {
|
|
112
112
|
const doc: Omit<CourseLookupDoc, '_rev'> = {
|
|
113
113
|
_id: courseId,
|
|
114
114
|
name: courseName,
|
|
115
115
|
};
|
|
116
|
-
|
|
116
|
+
|
|
117
117
|
if (disambiguator) {
|
|
118
118
|
doc.disambiguator = disambiguator;
|
|
119
119
|
}
|
|
@@ -130,6 +130,7 @@ export default class CourseLookup {
|
|
|
130
130
|
return await CourseLookup._db.remove(doc);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// [ ] rename to allCourses()
|
|
133
134
|
static async allCourseWare(): Promise<CourseLookupDoc[]> {
|
|
134
135
|
const resp = await CourseLookup._db.allDocs<CourseLookupDoc>({
|
|
135
136
|
include_docs: true,
|
package/src/impl/couch/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function hexEncode(str: string): string {
|
|
|
38
38
|
|
|
39
39
|
return returnStr;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const pouchDBincludeCredentialsConfig: PouchDB.Configuration.RemoteDatabaseConfiguration = {
|
|
42
42
|
fetch(url: string | Request, opts: RequestInit): Promise<Response> {
|
|
43
43
|
opts.credentials = 'include';
|
|
44
44
|
|
|
@@ -46,10 +46,42 @@ export const pouchDBincludeCredentialsConfig: PouchDB.Configuration.RemoteDataba
|
|
|
46
46
|
},
|
|
47
47
|
} as PouchDB.Configuration.RemoteDatabaseConfiguration;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Creates PouchDB configuration with appropriate authentication method
|
|
51
|
+
* - Uses HTTP Basic Auth when credentials are available (Node.js/MCP)
|
|
52
|
+
* - Falls back to cookie auth for browser environments
|
|
53
|
+
*/
|
|
54
|
+
export function createPouchDBConfig(): PouchDB.Configuration.RemoteDatabaseConfiguration {
|
|
55
|
+
// Check if running in Node.js with explicit credentials
|
|
56
|
+
const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
|
|
57
|
+
const isNodeEnvironment = typeof window === 'undefined';
|
|
58
|
+
|
|
59
|
+
if (hasExplicitCredentials && isNodeEnvironment) {
|
|
60
|
+
// Use HTTP Basic Auth for Node.js environments (MCP server)
|
|
61
|
+
return {
|
|
62
|
+
fetch(url: string | Request, opts: RequestInit = {}): Promise<Response> {
|
|
63
|
+
const basicAuth = btoa(`${ENV.COUCHDB_USERNAME}:${ENV.COUCHDB_PASSWORD}`);
|
|
64
|
+
const headers = new Headers(opts.headers || {});
|
|
65
|
+
headers.set('Authorization', `Basic ${basicAuth}`);
|
|
66
|
+
|
|
67
|
+
const newOpts = {
|
|
68
|
+
...opts,
|
|
69
|
+
headers: headers
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (pouch as any).fetch(url, newOpts);
|
|
73
|
+
}
|
|
74
|
+
} as PouchDB.Configuration.RemoteDatabaseConfiguration;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Use cookie-based auth for browser environments or when no explicit credentials
|
|
78
|
+
return pouchDBincludeCredentialsConfig;
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
function getCouchDB(dbName: string): PouchDB.Database {
|
|
50
82
|
return new pouch(
|
|
51
83
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
52
|
-
|
|
84
|
+
createPouchDBConfig()
|
|
53
85
|
);
|
|
54
86
|
}
|
|
55
87
|
|
|
@@ -57,7 +89,7 @@ export function getCourseDB(courseID: string): PouchDB.Database {
|
|
|
57
89
|
// todo: keep a cache of opened courseDBs? need to benchmark this somehow
|
|
58
90
|
return new pouch(
|
|
59
91
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + 'coursedb-' + courseID,
|
|
60
|
-
|
|
92
|
+
createPouchDBConfig()
|
|
61
93
|
);
|
|
62
94
|
}
|
|
63
95
|
|
|
@@ -188,7 +220,7 @@ export function getCouchUserDB(username: string): PouchDB.Database {
|
|
|
188
220
|
// see: https://github.com/pouchdb-community/pouchdb-authentication/issues/239
|
|
189
221
|
const ret = new pouch(
|
|
190
222
|
ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + dbName,
|
|
191
|
-
|
|
223
|
+
createPouchDBConfig()
|
|
192
224
|
);
|
|
193
225
|
if (guestAccount) {
|
|
194
226
|
updateGuestAccountExpirationDate(ret);
|
|
@@ -44,51 +44,74 @@ export default class UpdateQueue extends Loggable {
|
|
|
44
44
|
): Promise<T & PouchDB.Core.GetMeta & PouchDB.Core.RevisionIdMeta> {
|
|
45
45
|
logger.debug(`Applying updates on doc: ${id}`);
|
|
46
46
|
if (this.inprogressUpdates[id]) {
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Poll instead of recursing to avoid infinite recursion
|
|
48
|
+
while (this.inprogressUpdates[id]) {
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, Math.random() * 50));
|
|
50
|
+
}
|
|
50
51
|
return this.applyUpdates<T>(id);
|
|
51
52
|
} else {
|
|
52
53
|
if (this.pendingUpdates[id] && this.pendingUpdates[id].length > 0) {
|
|
53
54
|
this.inprogressUpdates[id] = true;
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
const MAX_RETRIES = 5;
|
|
57
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
58
|
+
try {
|
|
59
|
+
const doc = await this.readDB.get<T>(id);
|
|
60
|
+
logger.debug(`Retrieved doc: ${id}`);
|
|
61
|
+
|
|
62
|
+
// Create a new doc object to apply updates to for this attempt
|
|
63
|
+
let updatedDoc = { ...doc };
|
|
64
|
+
|
|
65
|
+
// Note: This loop is not fully safe if updates are functions that depend on a specific doc state
|
|
66
|
+
// that might change between retries. But for simple object merges, it's okay.
|
|
67
|
+
const updatesToApply = [...this.pendingUpdates[id]];
|
|
68
|
+
for (const update of updatesToApply) {
|
|
69
|
+
if (typeof update === 'function') {
|
|
70
|
+
updatedDoc = { ...updatedDoc, ...update(updatedDoc) };
|
|
71
|
+
} else {
|
|
72
|
+
updatedDoc = {
|
|
73
|
+
...updatedDoc,
|
|
74
|
+
...update,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
67
77
|
}
|
|
68
|
-
}
|
|
69
|
-
// for (const k in doc) {
|
|
70
|
-
// console.log(`${k}: ${typeof k}`);
|
|
71
|
-
// }
|
|
72
|
-
// console.log(`Applied updates to doc: ${JSON.stringify(doc)}`);
|
|
73
|
-
await this.writeDB.put<T>(doc);
|
|
74
|
-
logger.debug(`Put doc: ${id}`);
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
await this.writeDB.put<T>(updatedDoc);
|
|
80
|
+
logger.debug(`Put doc: ${id}`);
|
|
81
|
+
|
|
82
|
+
// Success! Remove the updates we just applied.
|
|
83
|
+
this.pendingUpdates[id].splice(0, updatesToApply.length);
|
|
84
|
+
|
|
85
|
+
if (this.pendingUpdates[id].length === 0) {
|
|
86
|
+
this.inprogressUpdates[id] = false;
|
|
87
|
+
delete this.inprogressUpdates[id];
|
|
88
|
+
} else {
|
|
89
|
+
// More updates came in, run again.
|
|
90
|
+
return this.applyUpdates<T>(id);
|
|
91
|
+
}
|
|
92
|
+
return updatedDoc as any; // success, exit loop and function
|
|
93
|
+
} catch (e: any) {
|
|
94
|
+
if (e.name === 'conflict' && i < MAX_RETRIES - 1) {
|
|
95
|
+
logger.warn(`Conflict on update for doc ${id}, retry #${i + 1}`);
|
|
96
|
+
await new Promise((res) => setTimeout(res, 50 * Math.random()));
|
|
97
|
+
// continue to next iteration of the loop
|
|
98
|
+
} else if (e.name === 'not_found' && i === 0) {
|
|
99
|
+
// Document not present - throw to caller for initialization
|
|
100
|
+
logger.warn(`Update failed for ${id} - does not exist. Throwing to caller.`);
|
|
101
|
+
throw e; // Let caller handle
|
|
102
|
+
} else {
|
|
103
|
+
// Max retries reached or a non-conflict error
|
|
104
|
+
delete this.inprogressUpdates[id];
|
|
105
|
+
if (this.pendingUpdates[id]) {
|
|
106
|
+
delete this.pendingUpdates[id];
|
|
107
|
+
}
|
|
108
|
+
logger.error(`Error on attemped update (retry ${i}): ${JSON.stringify(e)}`);
|
|
109
|
+
throw e; // Let caller handle
|
|
110
|
+
}
|
|
88
111
|
}
|
|
89
|
-
logger.error(`Error on attemped update: ${JSON.stringify(e)}`);
|
|
90
|
-
throw e; // Let caller handle (e.g., putCardRecord's 404 handling)
|
|
91
112
|
}
|
|
113
|
+
// This should be unreachable, but it satisfies the compiler that a value is always returned or an error thrown.
|
|
114
|
+
throw new Error(`UpdateQueue failed for doc ${id} after ${MAX_RETRIES} retries.`);
|
|
92
115
|
} else {
|
|
93
116
|
throw new Error(`Empty Updates Queue Triggered`);
|
|
94
117
|
}
|