@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.
Files changed (76) hide show
  1. package/dist/core/index.d.mts +7 -6
  2. package/dist/core/index.d.ts +7 -6
  3. package/dist/core/index.js +358 -87
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +358 -87
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{dataLayerProvider-DqtNroSh.d.ts → dataLayerProvider-BiP3kWix.d.mts} +8 -1
  8. package/dist/{dataLayerProvider-BInqI_RF.d.mts → dataLayerProvider-DSdeyRT3.d.ts} +8 -1
  9. package/dist/impl/couch/index.d.mts +19 -7
  10. package/dist/impl/couch/index.d.ts +19 -7
  11. package/dist/impl/couch/index.js +375 -100
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +374 -99
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.mts +23 -8
  16. package/dist/impl/static/index.d.ts +23 -8
  17. package/dist/impl/static/index.js +289 -85
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +289 -85
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-CUNnL38E.d.mts → index-Bmll7Xse.d.mts} +1 -1
  22. package/dist/{index-CLL31bEy.d.ts → index-CD8BZz2k.d.ts} +1 -1
  23. package/dist/index.d.mts +123 -20
  24. package/dist/index.d.ts +123 -20
  25. package/dist/index.js +1133 -343
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1137 -343
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/pouch/index.d.mts +1 -0
  30. package/dist/pouch/index.d.ts +1 -0
  31. package/dist/pouch/index.js +49 -0
  32. package/dist/pouch/index.js.map +1 -0
  33. package/dist/pouch/index.mjs +16 -0
  34. package/dist/pouch/index.mjs.map +1 -0
  35. package/dist/{types-BefDGkKa.d.ts → types-CewsN87z.d.ts} +1 -1
  36. package/dist/{types-DC-ckZug.d.mts → types-Dbp5DaRR.d.mts} +1 -1
  37. package/dist/{types-legacy-Birv-Jx6.d.mts → types-legacy-6ettoclI.d.mts} +17 -2
  38. package/dist/{types-legacy-Birv-Jx6.d.ts → types-legacy-6ettoclI.d.ts} +17 -2
  39. package/dist/{userDB-DusL7OXe.d.ts → userDB-C4yyAnpp.d.mts} +89 -56
  40. package/dist/{userDB-C33Hzjgn.d.mts → userDB-CD6s6ZCp.d.ts} +89 -56
  41. package/dist/util/packer/index.d.mts +3 -3
  42. package/dist/util/packer/index.d.ts +3 -3
  43. package/package.json +3 -3
  44. package/src/core/interfaces/contentSource.ts +3 -2
  45. package/src/core/interfaces/courseDB.ts +26 -3
  46. package/src/core/interfaces/dataLayerProvider.ts +9 -1
  47. package/src/core/interfaces/userDB.ts +80 -64
  48. package/src/core/navigators/elo.ts +10 -7
  49. package/src/core/navigators/hardcodedOrder.ts +64 -0
  50. package/src/core/navigators/index.ts +2 -1
  51. package/src/core/types/contentNavigationStrategy.ts +2 -1
  52. package/src/core/types/types-legacy.ts +7 -2
  53. package/src/impl/common/BaseUserDB.ts +60 -14
  54. package/src/impl/couch/CouchDBSyncStrategy.ts +2 -2
  55. package/src/impl/couch/PouchDataLayerProvider.ts +21 -0
  56. package/src/impl/couch/adminDB.ts +2 -2
  57. package/src/impl/couch/auth.ts +13 -4
  58. package/src/impl/couch/classroomDB.ts +10 -12
  59. package/src/impl/couch/courseAPI.ts +2 -2
  60. package/src/impl/couch/courseDB.ts +204 -38
  61. package/src/impl/couch/courseLookupDB.ts +4 -3
  62. package/src/impl/couch/index.ts +36 -4
  63. package/src/impl/couch/pouchdb-setup.ts +3 -3
  64. package/src/impl/couch/updateQueue.ts +59 -36
  65. package/src/impl/static/StaticDataLayerProvider.ts +68 -17
  66. package/src/impl/static/courseDB.ts +64 -20
  67. package/src/impl/static/coursesDB.ts +10 -6
  68. package/src/pouch/index.ts +2 -0
  69. package/src/study/ItemQueue.ts +58 -0
  70. package/src/study/SessionController.ts +182 -111
  71. package/src/study/SpacedRepetition.ts +1 -1
  72. package/src/study/services/CardHydrationService.ts +153 -0
  73. package/src/study/services/EloService.ts +85 -0
  74. package/src/study/services/ResponseProcessor.ts +224 -0
  75. package/src/study/services/SrsService.ts +44 -0
  76. package/tsup.config.ts +1 -0
@@ -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('CouchDB server configuration not properly initialized');
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
- const url = `${ENV.COUCHDB_SERVER_PROTOCOL}://${ENV.COUCHDB_SERVER_URL}_session`;
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
- logger.error(`Session check error: ${error}`);
61
- throw new Error(`Session check failed: ${error}`);
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
- pouchDBincludeCredentialsConfig
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(`New Cards from classroom ${this._cfg.name}: ${ret.map((c) => c.qualifiedID)}`);
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.qualifiedID.includes(ac))) {
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
- pouchDBincludeCredentialsConfig
210
+ createPouchDBConfig()
213
211
  );
214
212
  this._stuDb = new pouch(
215
213
  ENV.COUCHDB_SERVER_PROTOCOL + '://' + ENV.COUCHDB_SERVER_URL + stuDbName,
216
- pouchDBincludeCredentialsConfig
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
- pouchDBincludeCredentialsConfig
298
+ createPouchDBConfig()
301
299
  );
302
300
  }
303
301
 
@@ -1,5 +1,5 @@
1
1
  import pouch from './pouchdb-setup';
2
- import { pouchDBincludeCredentialsConfig } from '.';
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
- pouchDBincludeCredentialsConfig
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 { CardData, DocType, SkuilderCourseData, Tag, TagStub } from '../../core/types/types-legacy';
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
- // TODO: remove card from tags lists (getTagsByCards)
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
- // console.log(JSON.stringify(below));
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) => `${this.id}-${c.id}-${c.key}`);
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(this.id, cardId, tagId, (await this._getCurrentUser()).getUsername(), updateELO);
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
- getAllNavigationStrategies(): Promise<ContentNavigationStrategyData[]> {
454
- logger.debug('[courseDB] Returning hard-coded navigation strategies');
455
- const strategies: ContentNavigationStrategyData[] = [
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
- return Promise.resolve(strategies);
492
+ };
493
+ return Promise.resolve(strategy);
494
+ } else {
495
+ return this.db.get(id);
496
+ }
467
497
  }
468
498
 
469
- addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
470
- logger.debug(`[courseDB] Adding navigation strategy: ${data.id}`);
471
- // For now, just log the data and return success
472
- logger.debug(JSON.stringify(data));
473
- return Promise.resolve();
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
- id: 'ELO',
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: string) => boolean
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
- // console.log(`Picked ${targetElo} from [${bounds.low}, ${bounds.high}]`);
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: string[] = [];
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: string[] = [];
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: split[1],
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,
@@ -38,7 +38,7 @@ export function hexEncode(str: string): string {
38
38
 
39
39
  return returnStr;
40
40
  }
41
- export const pouchDBincludeCredentialsConfig: PouchDB.Configuration.RemoteDatabaseConfiguration = {
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
- pouchDBincludeCredentialsConfig
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
- pouchDBincludeCredentialsConfig
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
- pouchDBincludeCredentialsConfig
223
+ createPouchDBConfig()
192
224
  );
193
225
  if (guestAccount) {
194
226
  updateGuestAccountExpirationDate(ret);
@@ -8,9 +8,9 @@ PouchDB.plugin(PouchDBAuth);
8
8
 
9
9
  // Configure PouchDB globally
10
10
  PouchDB.defaults({
11
- ajax: {
12
- timeout: 60000,
13
- },
11
+ // ajax: {
12
+ // timeout: 60000,
13
+ // },
14
14
  });
15
15
 
16
16
  export default PouchDB;
@@ -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
- // console.log(`Updates in progress...`);
48
- await this.readDB.info(); // stall for a round trip
49
- // console.log(`Retrying...`);
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
- try {
56
- let doc = await this.readDB.get<T>(id);
57
- logger.debug(`Retrieved doc: ${id}`);
58
- while (this.pendingUpdates[id].length !== 0) {
59
- const update = this.pendingUpdates[id].splice(0, 1)[0];
60
- if (typeof update === 'function') {
61
- doc = { ...doc, ...update(doc) };
62
- } else {
63
- doc = {
64
- ...doc,
65
- ...update,
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
- if (this.pendingUpdates[id].length === 0) {
77
- this.inprogressUpdates[id] = false;
78
- delete this.inprogressUpdates[id];
79
- } else {
80
- return this.applyUpdates<T>(id);
81
- }
82
- return doc;
83
- } catch (e) {
84
- // Clean up queue state before re-throwing
85
- delete this.inprogressUpdates[id];
86
- if (this.pendingUpdates[id]) {
87
- delete this.pendingUpdates[id];
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
  }