import { CourseDBInterface, CourseInfo, CoursesDBInterface, UserDBInterface } from '@db/core'; import type { GeneratorResult, ReplanHints } from '@db/core/navigators/generators/types'; import { CourseConfig, CourseElo, DataShape, EloToNumber, Status, blankCourseElo, toCourseElo, } from '@vue-skuilder/common'; import { filterAllDocsByPrefix, getCourseDB } from '.'; import UpdateQueue from './updateQueue'; import { StudySessionItem } from '../../core/interfaces/contentSource'; import { CardData, DocType, QualifiedCardID, SkuilderCourseData, Tag, TagStub, DocTypePrefixes, } from '../../core/types/types-legacy'; import { logger } from '../../util/logger'; import { GET_CACHED } from './clientCache'; import { addNote55, addTagToCard, getCredentialledCourseConfig, getTagID } from './courseAPI'; import { DataLayerResult } from '@db/core/types/db'; import { PouchError } from './types'; import CourseLookup from './courseLookupDB'; import { ContentNavigationStrategyData } from '@db/core/types/contentNavigationStrategy'; import { ContentNavigator, Navigators } from '@db/core/navigators'; import { PipelineAssembler } from '@db/core/navigators/PipelineAssembler'; import { createDefaultPipeline } from '@db/core/navigators/defaults'; export class CoursesDB implements CoursesDBInterface { _courseIDs: string[] | undefined; constructor(courseIDs?: string[]) { if (courseIDs && courseIDs.length > 0) { this._courseIDs = courseIDs; } else { this._courseIDs = undefined; } } public async getCourseList(): Promise { let crsList = await CourseLookup.allCourseWare(); logger.debug(`AllCourses: ${crsList.map((c) => c.name + ', ' + c._id + '\n\t')}`); if (this._courseIDs) { crsList = crsList.filter((c) => this._courseIDs!.includes(c._id)); } logger.debug(`AllCourses.filtered: ${crsList.map((c) => c.name + ', ' + c._id + '\n\t')}`); const cfgs = await Promise.all( crsList.map(async (c) => { try { const cfg = await getCredentialledCourseConfig(c._id); logger.debug(`Found cfg: ${JSON.stringify(cfg)}`); return cfg; } catch (e) { logger.warn(`Error fetching cfg for course ${c.name}, ${c._id}: ${e}`); return undefined; } }) ); return cfgs.filter((c) => !!c); } async getCourseConfig(courseId: string): Promise { if (this._courseIDs && this._courseIDs.length && !this._courseIDs.includes(courseId)) { throw new Error(`Course ${courseId} not in course list`); } const cfg = await getCredentialledCourseConfig(courseId); if (cfg === undefined) { throw new Error(`Error fetching cfg for course ${courseId}`); } else { return cfg; } } public async disambiguateCourse(courseId: string, disambiguator: string): Promise { await CourseLookup.updateDisambiguator(courseId, disambiguator); } } function randIntWeightedTowardZero(n: number) { return Math.floor(Math.random() * Math.random() * Math.random() * n); } export class CourseDB implements CourseDBInterface { // private log(msg: string): void { // log(`CourseLog: ${this.id}\n ${msg}`); // } /** * Primary database handle used for all **read** operations (queries, gets). * * When local sync is active, this points to the local PouchDB replica for * fast, network-free reads. Otherwise it points to the remote CouchDB. */ private db: PouchDB.Database; /** * Remote database handle used for all **write** operations. * * Always points to the remote CouchDB so that writes (ELO updates, tag * mutations, admin operations) aggregate on the server. The local replica * is a read-only snapshot that refreshes on the next page load. * * When local sync is NOT active, this is the same instance as `this.db`. */ private remoteDB: PouchDB.Database; private id: string; private _getCurrentUser: () => Promise; private updateQueue: UpdateQueue; /** * @param id - Course ID * @param userLookup - Async function returning the current user DB * @param localDB - Optional local PouchDB replica for reads. When provided, * `this.db` uses the local replica and `this.remoteDB` stays remote. * The UpdateQueue reads from remote and writes to remote (local `_rev` * values may be stale, so read-modify-write cycles must go through * the remote DB to avoid conflicts). */ constructor(id: string, userLookup: () => Promise, localDB?: PouchDB.Database) { this.id = id; const remote = getCourseDB(this.id); this.remoteDB = remote; this.db = localDB ?? remote; this._getCurrentUser = userLookup; // UpdateQueue always operates against the remote DB for its // read-modify-write cycle. Local _rev values may be stale (the local // replica is a snapshot), so conflict retries must read from remote. this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB); } public getCourseID(): string { return this.id; } public async getCourseInfo(): Promise { const cardCount = ( await this.db.find({ selector: { docType: DocType.CARD, }, limit: 1000, }) ).docs.length; return { cardCount, registeredUsers: 0, }; } public async getInexperiencedCards(limit: number = 2) { return ( await this.db.query('cardsByInexperience', { limit, }) ).rows.map((r) => { const ret = { courseId: this.id, cardId: r.id, count: r.key, elo: r.value, }; return ret; }); } public async getCardsByEloLimits( options: { low: number; high: number; limit: number; page: number; } = { low: 0, high: Number.MIN_SAFE_INTEGER, limit: 25, page: 0, } ) { return ( await this.db.query('elo', { startkey: options.low, endkey: options.high, limit: options.limit, skip: options.limit * options.page, }) ).rows.map((r) => { return `${this.id}-${r.id}-${r.key}`; }); } public async getCardEloData(id: string[]): Promise { const docs = await this.db.allDocs({ keys: id, include_docs: true, }); const ret: CourseElo[] = []; docs.rows.forEach((r) => { // [ ] remove these ts-ignore directives. if (isSuccessRow(r)) { if (r.doc && r.doc.elo) { ret.push(toCourseElo(r.doc.elo)); } else { logger.warn('no elo data for card: ' + r.id); ret.push(blankCourseElo()); } } else { logger.warn('no elo data for card: ' + JSON.stringify(r)); ret.push(blankCourseElo()); } }); return ret; } /** * Returns the lowest and highest `global` ELO ratings in the course */ public async getELOBounds() { const [low, high] = await Promise.all([ ( await this.db.query('elo', { startkey: 0, limit: 1, include_docs: false, }) ).rows[0].key, ( await this.db.query('elo', { limit: 1, descending: true, startkey: 100_000, }) ).rows[0].key, ]); return { low: low, high: high, }; } public async removeCard(id: string) { // Admin operation — read and write both go through remote DB to ensure // we have the current _rev for the delete. const doc = await this.remoteDB.get(id); if (!doc.docType || !(doc.docType === DocType.CARD)) { throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`); } // Remove card from all associated tags before deleting the card try { const appliedTags = await this.getAppliedTags(id); const results = await Promise.allSettled( appliedTags.rows.map(async (tagRow) => { const tagId = tagRow.id; await this.removeTagFromCard(id, tagId); }) ); // Log any individual tag cleanup failures results.forEach((result, index) => { if (result.status === 'rejected') { const tagId = appliedTags.rows[index].id; logger.error(`Failed to remove card ${id} from tag ${tagId}: ${result.reason}`); } }); } catch (error) { logger.error(`Error removing card ${id} from tags: ${error}`); // Continue with card deletion even if tag cleanup fails } return this.remoteDB.remove(doc); } public async getCardDisplayableDataIDs(id: string[]) { logger.debug(id.join(', ')); const cards = await this.db.allDocs({ keys: id, include_docs: true, }); const ret: { [card: string]: string[] } = {}; cards.rows.forEach((r) => { if (isSuccessRow(r)) { ret[r.id] = r.doc!.id_displayable_data; } }); return ret; } async getCardsByELO(elo: number, cardLimit?: number) { elo = parseInt(elo as any); const limit = cardLimit ? cardLimit : 25; // const tQ0 = performance.now(); // NOTE: `stale: 'update_after'` was tried here and removed — it gave no // measurable speedup (PouchDB 9 effectively ignores it for the reindex // cost) AND it can return an empty result on the first query after a cold // DB open (index not yet loaded), which then poisons the pool cache. The // session pool cache (see getCardsCenteredAtELO) is what removes the // per-run cost, so we read the view normally (always-fresh) here. const below: PouchDB.Query.Response = await this.db.query('elo', { limit: Math.ceil(limit / 2), startkey: elo, descending: true, }); // const tBelowQ = performance.now(); const aboveLimit = limit - below.rows.length; const above: PouchDB.Query.Response = await this.db.query('elo', { limit: aboveLimit, startkey: elo + 1, }); // const tAbove = performance.now(); // [perf] parked: getCardsByELO view-query timing (below/above split) // logger.info( // `[perf][getCardsByELO] reqLimit=${limit} ` + // `below=${(tBelowQ - tQ0).toFixed(0)}ms(${below.rows.length}r) ` + // `above=${(tAbove - tBelowQ).toFixed(0)}ms(${above.rows.length}r)` // ); let cards = below.rows; cards = cards.concat(above.rows); const ret = cards .sort((a, b) => { const s = Math.abs(a.key - elo) - Math.abs(b.key - elo); if (s === 0) { return Math.random() - 0.5; } else { return s; } }) .map((c) => { return { courseID: this.id, cardID: c.id, elo: c.key, }; }); const str = `below:\n${below.rows.map((r) => `\t${r.id}-${r.key}\n`)} above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`; logger.debug(`Getting ${limit} cards centered around elo: ${elo}:\n\n` + str); return ret; } async getCourseConfig(): Promise { const ret = await getCredentialledCourseConfig(this.id); if (ret) { return ret; } else { throw new Error(`Course config not found for course ID: ${this.id}`); } } async updateCourseConfig(cfg: CourseConfig): Promise { logger.debug(`Updating: ${JSON.stringify(cfg)}`); // write both to the course DB: try { return await updateCredentialledCourseConfig(this.id, cfg); } catch (error) { logger.error(`Error updating course config in course DB: ${error}`); throw error; } } async updateCardElo(cardId: string, elo: CourseElo): Promise { if (!elo) { throw new Error(`Cannot update card elo with null or undefined value for card ID: ${cardId}`); } try { const result = await this.updateQueue.update< CardData & PouchDB.Core.GetMeta & PouchDB.Core.IdMeta >(cardId, (card) => { logger.debug(`Replacing ${JSON.stringify(card.elo)} with ${JSON.stringify(elo)}`); card.elo = elo; return card; }); return { ok: true, id: cardId, rev: result._rev }; } catch (error) { logger.error(`Failed to update card elo for card ID: ${cardId}`, error); throw new Error(`Failed to update card elo for card ID: ${cardId}`); } } async getAppliedTags(cardId: string): Promise> { const ret = await getAppliedTags(this.id, cardId); if (ret) { return ret; } else { throw new Error(`Failed to find tags for card ${this.id}-${cardId}`); } } async getAppliedTagsBatch(cardIds: string[]): Promise> { if (cardIds.length === 0) { return new Map(); } const result = await this.db.query('getTags', { keys: cardIds, include_docs: false, }); const tagsByCard = new Map(); // Initialize all requested cards with empty arrays for (const cardId of cardIds) { tagsByCard.set(cardId, []); } // Populate from query results for (const row of result.rows) { const cardId = row.key as string; const tagName = row.value?.name; if (tagName && tagsByCard.has(cardId)) { tagsByCard.get(cardId)!.push(tagName); } } return tagsByCard; } async getAllCardIds(): Promise { // Card `_id`s are minted as `-<...>` (see courseAPI), // i.e. the `c-` prefix \u2014 for both framework-minted uuid ids (`c-`) and // course-authored deterministic ids (e.g. LettersPractice's `c-intro-s-S`). // // The previous implementation hardcoded a `CARD-` prefix range, which never // matched any card and silently returned an EMPTY list, starving the only // callers (`forecast()` / `diagnoseCardSpace()`). Use the prefix const so // the range stays correct if the scheme ever changes. const prefix = `${DocTypePrefixes[DocType.CARD]}-`; const result = await this.db.allDocs({ startkey: prefix, endkey: `${prefix}\ufff0`, include_docs: false, }); return result.rows.map((row) => row.id); } async addTagToCard( cardId: string, tagId: string, updateELO?: boolean ): Promise { return await addTagToCard( this.id, cardId, tagId, (await this._getCurrentUser()).getUsername(), updateELO ); } async removeTagFromCard(cardId: string, tagId: string): Promise { return await removeTagFromCard(this.id, cardId, tagId); } async createTag(name: string, author: string): Promise { return await createTag(this.id, name, author); } async getTag(tagId: string): Promise> { return await getTag(this.id, tagId); } async updateTag(tag: Tag): Promise { if (tag.course !== this.id) { throw new Error(`Tag ${JSON.stringify(tag)} does not belong to course ${this.id}`); } return await updateTag(tag); } async getCourseTagStubs(): Promise> { return getCourseTagStubs(this.id); } async addNote( codeCourse: string, shape: DataShape, data: unknown, author: string, tags: string[], uploads?: { [key: string]: PouchDB.Core.FullAttachment }, elo: CourseElo = blankCourseElo() ): Promise { try { const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo); if (resp.ok) { // Check if card creation failed (property added by addNote55) if ((resp as any).cardCreationFailed) { logger.warn( `[courseDB.addNote] Note added but card creation failed: ${ (resp as any).cardCreationError }` ); return { status: Status.error, message: `Note was added but no cards were created: ${(resp as any).cardCreationError}`, id: resp.id, }; } return { status: Status.ok, message: '', id: resp.id, }; } else { return { status: Status.error, message: 'Unexpected error adding note', }; } } catch (e) { const err = e as PouchDB.Core.Error; logger.error( `[addNote] error ${err.name}\n\treason: ${err.reason}\n\tmessage: ${err.message}` ); return { status: Status.error, message: `Error adding note to course. ${(e as PouchError).reason || err.message}`, }; } } async getCourseDoc( id: string, options?: PouchDB.Core.GetOptions ): Promise> { // Use this.db (local when available) for read operations. // Falls back to the standalone helper (always remote) only if needed. return await this.db.get(id, options ?? {}) as PouchDB.Core.GetMeta & PouchDB.Core.Document; } async getCourseDocs( ids: string[], options: PouchDB.Core.AllDocsOptions = {} ): Promise> { // Use this.db (local when available) for read operations. return await this.db.allDocs({ ...options, keys: ids, }) as PouchDB.Core.AllDocsWithKeysResponse<{} & T>; } //////////////////////////////////// // NavigationStrategyManager implementation //////////////////////////////////// getNavigationStrategy(id: string): Promise { logger.debug(`[courseDB] Getting navigation strategy: ${id}`); if (id == '') { const strategy: ContentNavigationStrategyData = { _id: 'NAVIGATION_STRATEGY-ELO', docType: DocType.NAVIGATION_STRATEGY, name: 'ELO', description: 'ELO-based navigation strategy for ordering content by difficulty', implementingClass: Navigators.ELO, course: this.id, serializedData: '', // serde is a noop for ELO navigator. }; return Promise.resolve(strategy); } else { return this.db.get(id); } } async getAllNavigationStrategies(): Promise { const prefix = DocTypePrefixes[DocType.NAVIGATION_STRATEGY]; const result = await this.db.allDocs({ startkey: prefix, endkey: `${prefix}\ufff0`, include_docs: true, }); return result.rows.map((row) => row.doc!); } async addNavigationStrategy(data: ContentNavigationStrategyData): Promise { logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`); // Strategy set changed — drop the cached navigator so it rebuilds. this.invalidateNavigatorCache(); // Admin write operation — use remote DB. return this.remoteDB.put(data).then(() => {}); } updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise { logger.debug(`[courseDB] Updating navigation strategy: ${id}`); // For now, just log the data and return success logger.debug(JSON.stringify(data)); return Promise.resolve(); } /** * Creates an instantiated navigator for this course. * * Handles multiple generators by wrapping them in CompositeGenerator. * This is the preferred method for getting a ready-to-use navigator. * * @param user - User database interface * @returns Instantiated ContentNavigator ready for use */ async createNavigator(user: UserDBInterface): Promise { try { const allStrategies = await this.getAllNavigationStrategies(); if (allStrategies.length === 0) { // No strategies configured: use default Pipeline(Composite(ELO, SRS), [eloDistanceFilter]) logger.debug( '[courseDB] No strategy documents found, using default Pipeline(Composite(ELO, SRS), [eloDistanceFilter])' ); return createDefaultPipeline(user, this); } // Use PipelineAssembler to build a Pipeline from strategy documents const assembler = new PipelineAssembler(); const { pipeline, generatorStrategies, filterStrategies, warnings } = await assembler.assemble({ strategies: allStrategies, user, course: this, }); // Log any warnings from assembly for (const warning of warnings) { logger.warn(`[PipelineAssembler] ${warning}`); } if (!pipeline) { // Assembly failed - fall back to default logger.debug('[courseDB] Pipeline assembly failed, using default pipeline'); return createDefaultPipeline(user, this); } logger.debug( `[courseDB] Using assembled pipeline with ${generatorStrategies.length} generator(s) and ${filterStrategies.length} filter(s)` ); return pipeline; } catch (e) { const msg = e instanceof Error ? `${e.message}\n${e.stack}` : JSON.stringify(e); logger.error(`[courseDB] Error creating navigator: ${msg}`); throw e; } } //////////////////////////////////// // END NavigationStrategyManager implementation //////////////////////////////////// //////////////////////////////////// // StudyContentSource implementation //////////////////////////////////// /** * Get cards with suitability scores for presentation. * * This is the PRIMARY API for content sources going forward. Delegates to the * course's configured NavigationStrategy to get scored candidates. * * @param limit - Maximum number of cards to return * @returns Cards sorted by score descending */ private _pendingHints: ReplanHints | null = null; /** * Session-scoped cache of the broad ELO-neighbor pool used by * getCardsCenteredAtELO. The `elo` view query re-indexes on first touch per * call (PouchDB 9 ignores `stale`), so without this each plan/replan pays * ~1.5-2s. The pool is fetched once and re-ranked against the live (roaming) * ELO in memory on subsequent calls. */ private _eloPoolCache: { rows: (QualifiedCardID & { elo?: number })[]; fetchedAt: number; } | null = null; private readonly _eloPoolTtlMs = 5 * 60 * 1000; /** * Cached assembled navigator (Pipeline). createNavigator() reads strategy * docs and builds a fresh Pipeline every call — whose internal `_tagCache` * and `_cachedOrchestration` are designed to make replans cheap but never * survive, because the instance is discarded each run. Caching it lets those * caches persist across plan/replan within a session (SessionController holds * one CourseDB instance for the session's lifetime). Rebuilt on user change, * TTL expiry, or explicit invalidation after a strategy-doc write. */ private _cachedNavigator: { navigator: ContentNavigator; userId: string; builtAt: number; } | null = null; private readonly _navigatorTtlMs = 5 * 60 * 1000; public setEphemeralHints(hints: ReplanHints): void { this._pendingHints = hints; } public async getWeightedCards(limit: number): Promise { const u = await this._getCurrentUser(); try { // const tNav0 = performance.now(); // [perf] parked const { navigator } = await this._getCachedNavigator(u); // const tNav1 = performance.now(); // [perf] parked if (this._pendingHints) { navigator.setEphemeralHints(this._pendingHints); this._pendingHints = null; } const result = await navigator.getWeightedCards(limit); // const tRun = performance.now(); // [perf] parked // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure // logger.info( // `[perf][courseDB] getWeightedCards(limit=${limit}): ` + // `navigator=${(tNav1 - tNav0).toFixed(0)}ms(${navCache}) ` + // `pipelineRun=${(tRun - tNav1).toFixed(0)}ms ` + // `total=${(tRun - tNav0).toFixed(0)}ms` // ); return result; } catch (e) { logger.error(`[courseDB] Error getting weighted cards: ${e}`); throw e; } } /** * Return the assembled navigator, reusing the cached instance when possible. * Reuse preserves the Pipeline's per-session caches (tags, orchestration * context) across replans, which is the dominant per-replan cost once the * ELO-pool cost is removed. Rebuilds on user change or TTL expiry. */ private async _getCachedNavigator( user: UserDBInterface ): Promise<{ navigator: ContentNavigator; cacheStatus: 'hit' | 'miss' }> { const userId = user.getUsername(); const now = Date.now(); if ( this._cachedNavigator && this._cachedNavigator.userId === userId && now - this._cachedNavigator.builtAt < this._navigatorTtlMs ) { return { navigator: this._cachedNavigator.navigator, cacheStatus: 'hit' }; } const navigator = await this.createNavigator(user); this._cachedNavigator = { navigator, userId, builtAt: now }; return { navigator, cacheStatus: 'miss' }; } /** * Drop the cached navigator so the next getWeightedCards() rebuilds it. * Call after mutating this course's navigation strategy documents. */ public invalidateNavigatorCache(): void { this._cachedNavigator = null; } public async getCardsCenteredAtELO( options: { limit: number; elo: 'user' | 'random' | number; } = { limit: 99, elo: 'user', }, filter?: (a: QualifiedCardID) => boolean ): Promise { // [perf] parked: getCardsCenteredAtELO rewrite banner // logger.info('[perf][run] getCardsCenteredAtELO rewrite (session pool cache + in-memory recenter)'); // const tCelo0 = performance.now(); let targetElo: number; if (options.elo === 'user') { const u = await this._getCurrentUser(); targetElo = -1; try { const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => { return c.courseID === this.id; })!; targetElo = EloToNumber(courseDoc.elo); } catch { targetElo = 1000; } } else if (options.elo === 'random') { const bounds = await GET_CACHED(`elo-bounds-${this.id}`, () => this.getELOBounds()); targetElo = Math.round(bounds.low + Math.random() * (bounds.high - bounds.low)); // logger.log(`Picked ${targetElo} from [${bounds.low}, ${bounds.high}]`); } else { targetElo = options.elo; } // const tReg = performance.now(); // Broad neighbor pool fetched once per session and re-used. We over-fetch // (POOL_SIZE >> limit) so that the in-memory active-card filter and the // slowly-roaming ELO both have ample headroom before a refetch is needed. const POOL_SIZE = Math.max(2000, options.limit * 4); const nowMs = Date.now(); let cacheStatus: 'hit' | 'miss' | 'refresh' = 'hit'; if (!this._eloPoolCache || nowMs - this._eloPoolCache.fetchedAt > this._eloPoolTtlMs) { // MISS: pay the (reindexing) view query once, then cache the raw pool. // Guard: never cache an EMPTY pool. A cold-DB-open or sync-race fetch can // transiently return [], and caching it would starve the session for the // whole TTL. Leaving the cache untouched lets the next call retry. const fetched = await this.getCardsByELO(targetElo, POOL_SIZE); if (fetched.length > 0) { this._eloPoolCache = { rows: fetched, fetchedAt: nowMs }; } cacheStatus = 'miss'; } // Apply the (fresh) caller filter, then re-center against the *current* ELO. // Returns a new array each call — the cached pool is never mutated, and the // ranking reflects the live ELO even as it drifts within a session. const rankAgainstCurrentElo = (): (QualifiedCardID & { elo?: number })[] => { const raw = this._eloPoolCache?.rows ?? []; const survivors = filter ? raw.filter((c) => filter(c)) : raw; return survivors .map((c) => ({ ...c })) .sort( (a, b) => Math.abs((a.elo ?? targetElo) - targetElo) - Math.abs((b.elo ?? targetElo) - targetElo) ); }; let cards = rankAgainstCurrentElo(); // Refetch once if the pool can't satisfy the limit — either the active-card // filter has grown past pool coverage (hit), or the pool is missing because // a prior fetch came back empty (cold open / sync race). A miss that cached // a non-empty-but-small pool (genuinely small course) is left alone. if (cards.length < options.limit && (cacheStatus === 'hit' || !this._eloPoolCache)) { const fetched = await this.getCardsByELO(targetElo, POOL_SIZE); if (fetched.length > 0) { this._eloPoolCache = { rows: fetched, fetchedAt: nowMs }; } cards = rankAgainstCurrentElo(); cacheStatus = 'refresh'; } // [perf] parked: centeredAtELO regDoc / pool-cache timing // logger.info( // `[perf][centeredAtELO] regDoc=${(tReg - tCelo0).toFixed(0)}ms ` + // `cache=${cacheStatus} build=${(performance.now() - tReg).toFixed(0)}ms ` + // `poolRaw=${this._eloPoolCache?.rows.length ?? 0} postFilter=${cards.length} ` + // `limit=${options.limit} targetElo=${targetElo}` // ); const selectedCards: { courseID: string; cardID: string; elo?: number; }[] = []; while (selectedCards.length < options.limit && cards.length > 0) { const index = randIntWeightedTowardZero(cards.length); const card = cards.splice(index, 1)[0]; selectedCards.push(card); } return selectedCards.map((c) => { return { courseID: this.id, cardID: c.cardID, contentSourceType: 'course', contentSourceID: this.id, elo: c.elo, status: 'new', }; }); } // Admin search methods public async searchCards(query: string): Promise { logger.log(`[CourseDB ${this.id}] Searching for: "${query}"`); // Try multiple search approaches let displayableData; try { // Try regex search on the correct data structure: data[0].data displayableData = await this.db.find({ selector: { docType: 'DISPLAYABLE_DATA', 'data.0.data': { $regex: `.*${query}.*` }, }, }); logger.log(`[CourseDB ${this.id}] Regex search on data[0].data successful`); } catch (regexError) { logger.log( `[CourseDB ${this.id}] Regex search failed, falling back to manual search:`, regexError ); // Fallback: get all displayable data and filter manually const allDisplayable = await this.db.find({ selector: { docType: 'DISPLAYABLE_DATA', }, }); logger.log( `[CourseDB ${this.id}] Retrieved ${allDisplayable.docs.length} documents for manual filtering` ); displayableData = { docs: allDisplayable.docs.filter((doc) => { // Search entire document as JSON string - inclusive approach for admin tool const docString = JSON.stringify(doc).toLowerCase(); const match = docString.includes(query.toLowerCase()); if (match) { logger.log(`[CourseDB ${this.id}] Manual match found in document: ${doc._id}`); } return match; }), }; } logger.log( `[CourseDB ${this.id}] Found ${displayableData.docs.length} displayable data documents` ); if (displayableData.docs.length === 0) { // Debug: Let's see what displayable data exists const allDisplayableData = await this.db.find({ selector: { docType: 'DISPLAYABLE_DATA', }, limit: 5, // Just sample a few }); logger.log( `[CourseDB ${this.id}] Sample displayable data:`, allDisplayableData.docs.map((d) => ({ id: d._id, docType: (d as any).docType, dataStructure: (d as any).data ? Object.keys((d as any).data) : 'no data field', dataContent: (d as any).data, fullDoc: d, })) ); } const allResults: any[] = []; for (const dd of displayableData.docs) { const cards = await this.db.find({ selector: { docType: 'CARD', id_displayable_data: { $in: [dd._id] }, }, }); logger.log( `[CourseDB ${this.id}] Displayable data ${dd._id} linked to ${cards.docs.length} cards` ); allResults.push(...cards.docs); } logger.log(`[CourseDB ${this.id}] Total cards found: ${allResults.length}`); return allResults; } public async find( request: PouchDB.Find.FindRequest ): Promise> { return this.db.find(request); } } /** * Returns a list of registered datashapes for the specified * course. * @param courseID The ID of the course */ export async function getCourseDataShapes(courseID: string) { const cfg = await getCredentialledCourseConfig(courseID); return cfg!.dataShapes; } export async function getCredentialledDataShapes(courseID: string) { const cfg = await getCredentialledCourseConfig(courseID); return cfg.dataShapes; } export async function getCourseQuestionTypes(courseID: string) { const cfg = await getCredentialledCourseConfig(courseID); return cfg!.questionTypes; } // todo: this is actually returning full tag docs now. // - performance issue when tags have lots of // applied docs // - will require a computed couch DB view export async function getCourseTagStubs( courseID: string ): Promise> { logger.debug(`Getting tag stubs for course: ${courseID}`); const stubs = await filterAllDocsByPrefix( getCourseDB(courseID), DocType.TAG.valueOf() + '-' ); stubs.rows.forEach((row) => { logger.debug(`\tTag stub for doc: ${row.id}`); }); return stubs; } export async function deleteTag(courseID: string, tagName: string) { tagName = getTagID(tagName); const courseDB = getCourseDB(courseID); const doc = await courseDB.get(DocType.TAG.valueOf() + '-' + tagName); const resp = await courseDB.remove(doc); return resp; } export async function createTag(courseID: string, tagName: string, author: string) { logger.debug(`Creating tag: ${tagName}...`); const tagID = getTagID(tagName); const courseDB = getCourseDB(courseID); const resp = await courseDB.put({ course: courseID, docType: DocType.TAG, name: tagName, snippet: '', taggedCards: [], wiki: '', author, _id: tagID, }); return resp; } export async function updateTag(tag: Tag) { const prior = await getTag(tag.course, tag.name); return await getCourseDB(tag.course).put({ ...tag, _rev: prior._rev, }); } export async function getTag(courseID: string, tagName: string) { const tagID = getTagID(tagName); const courseDB = getCourseDB(courseID); return courseDB.get(tagID); } export async function removeTagFromCard(courseID: string, cardID: string, tagID: string) { // todo: possible future perf. hit if tags have large #s of taggedCards. // In this case, should be converted to a server-request tagID = getTagID(tagID); const courseDB = getCourseDB(courseID); const tag = await courseDB.get(tagID); tag.taggedCards = tag.taggedCards.filter((taggedID) => { return cardID !== taggedID; }); return courseDB.put(tag); } /** * Returns an array of ancestor tag IDs, where: * return[0] = parent, * return[1] = grandparent, * return[2] = great grandparent, * etc. * * If ret is empty, the tag itself is a root */ export function getAncestorTagIDs(courseID: string, tagID: string): string[] { tagID = getTagID(tagID); const split = tagID.split('>'); if (split.length === 1) { return []; } else { split.pop(); const parent = split.join('>'); return [parent].concat(getAncestorTagIDs(courseID, parent)); } } export async function getChildTagStubs(courseID: string, tagID: string) { return await filterAllDocsByPrefix(getCourseDB(courseID), tagID + '>'); } export async function getAppliedTags(id_course: string, id_card: string) { const db = getCourseDB(id_course); const result = await db.query('getTags', { startkey: id_card, endkey: id_card, // include_docs: true }); // log(`getAppliedTags looked up: ${id_card}`); // log(`getAppliedTags returning: ${JSON.stringify(result)}`); return result; } export async function updateCardElo(courseID: string, cardID: string, elo: CourseElo) { if (elo) { // checking against null, undefined, NaN const cDB = getCourseDB(courseID); const card = await cDB.get(cardID); logger.debug(`Replacing ${JSON.stringify(card.elo)} with ${JSON.stringify(elo)}`); card.elo = elo; return cDB.put(card); // race conditions - is it important? probably not (net-zero effect) } } export async function updateCredentialledCourseConfig(courseID: string, config: CourseConfig) { logger.debug(`Updating course config: ${JSON.stringify(config)} `); const db = getCourseDB(courseID); const old = await getCredentialledCourseConfig(courseID); return await db.put({ ...config, _rev: (old as any)._rev, }); } function isSuccessRow( row: | { key: PouchDB.Core.DocumentKey; error: 'not_found'; } | { doc?: PouchDB.Core.ExistingDocument | null | undefined; id: PouchDB.Core.DocumentId; key: PouchDB.Core.DocumentKey; value: { rev: PouchDB.Core.RevisionId; deleted?: boolean | undefined; }; } ): row is { doc?: PouchDB.Core.ExistingDocument | null | undefined; id: PouchDB.Core.DocumentId; key: PouchDB.Core.DocumentKey; value: { rev: PouchDB.Core.RevisionId; deleted?: boolean | undefined; }; } { return 'doc' in row && row.doc !== null && row.doc !== undefined; }