@vue-skuilder/db 0.1.18 → 0.1.21

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 (87) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
  3. package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
  4. package/dist/core/index.d.cts +80 -6
  5. package/dist/core/index.d.ts +80 -6
  6. package/dist/core/index.js +735 -1560
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/index.mjs +708 -1539
  9. package/dist/core/index.mjs.map +1 -1
  10. package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
  11. package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
  12. package/dist/impl/couch/index.d.cts +8 -23
  13. package/dist/impl/couch/index.d.ts +8 -23
  14. package/dist/impl/couch/index.js +723 -1578
  15. package/dist/impl/couch/index.js.map +1 -1
  16. package/dist/impl/couch/index.mjs +692 -1552
  17. package/dist/impl/couch/index.mjs.map +1 -1
  18. package/dist/impl/static/index.d.cts +25 -8
  19. package/dist/impl/static/index.d.ts +25 -8
  20. package/dist/impl/static/index.js +700 -1400
  21. package/dist/impl/static/index.js.map +1 -1
  22. package/dist/impl/static/index.mjs +688 -1393
  23. package/dist/impl/static/index.mjs.map +1 -1
  24. package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
  25. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  26. package/dist/index.d.cts +71 -63
  27. package/dist/index.d.ts +71 -63
  28. package/dist/index.js +1162 -1996
  29. package/dist/index.js.map +1 -1
  30. package/dist/index.mjs +1124 -1955
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/pouch/index.js +3 -0
  33. package/dist/pouch/index.js.map +1 -1
  34. package/dist/pouch/index.mjs +3 -0
  35. package/dist/pouch/index.mjs.map +1 -1
  36. package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
  37. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  38. package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  39. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  40. package/dist/util/packer/index.d.cts +3 -3
  41. package/dist/util/packer/index.d.ts +3 -3
  42. package/docs/navigators-architecture.md +115 -17
  43. package/package.json +4 -4
  44. package/src/core/index.ts +1 -0
  45. package/src/core/interfaces/classroomDB.ts +5 -13
  46. package/src/core/interfaces/contentSource.ts +6 -66
  47. package/src/core/interfaces/courseDB.ts +15 -7
  48. package/src/core/interfaces/userDB.ts +32 -0
  49. package/src/core/navigators/Pipeline.ts +136 -52
  50. package/src/core/navigators/PipelineAssembler.ts +1 -1
  51. package/src/core/navigators/defaults.ts +84 -0
  52. package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
  53. package/src/core/navigators/filters/index.ts +3 -0
  54. package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
  55. package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
  56. package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
  57. package/src/core/navigators/filters/userGoalStub.ts +136 -0
  58. package/src/core/navigators/filters/userTagPreference.ts +217 -0
  59. package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
  60. package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
  61. package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
  62. package/src/core/navigators/generators/types.ts +1 -1
  63. package/src/core/navigators/index.ts +95 -91
  64. package/src/core/types/strategyState.ts +84 -0
  65. package/src/core/types/types-legacy.ts +2 -0
  66. package/src/impl/common/BaseUserDB.ts +74 -7
  67. package/src/impl/couch/adminDB.ts +1 -2
  68. package/src/impl/couch/classroomDB.ts +100 -103
  69. package/src/impl/couch/courseDB.ts +35 -91
  70. package/src/impl/couch/pouchdb-setup.ts +7 -0
  71. package/src/impl/static/StaticDataUnpacker.ts +50 -1
  72. package/src/impl/static/courseDB.ts +87 -37
  73. package/src/study/SessionController.ts +122 -202
  74. package/src/study/SourceMixer.ts +65 -0
  75. package/src/study/TagFilteredContentSource.ts +49 -92
  76. package/src/study/index.ts +1 -0
  77. package/src/study/services/CardHydrationService.ts +165 -81
  78. package/src/util/dataDirectory.ts +1 -1
  79. package/src/util/index.ts +0 -1
  80. package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
  81. package/tests/core/navigators/Pipeline.test.ts +6 -72
  82. package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
  83. package/tests/core/navigators/navigators.test.ts +118 -151
  84. package/docs/todo-pipeline-optimization.md +0 -117
  85. package/docs/todo-strategy-state-storage.md +0 -278
  86. package/src/core/navigators/hardcodedOrder.ts +0 -163
  87. package/src/util/tuiLogger.ts +0 -139
@@ -1,12 +1,12 @@
1
1
  import moment from 'moment';
2
- import type { ScheduledCard } from '../types/user';
3
- import type { CourseDBInterface } from '../interfaces/courseDB';
4
- import type { UserDBInterface } from '../interfaces/userDB';
5
- import { ContentNavigator } from './index';
6
- import type { WeightedCard } from './index';
7
- import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
8
- import type { StudySessionReviewItem, StudySessionNewItem } from '../interfaces/contentSource';
9
- import type { CardGenerator, GeneratorContext } from './generators/types';
2
+ import type { ScheduledCard } from '../../types/user';
3
+ import type { CourseDBInterface } from '../../interfaces/courseDB';
4
+ import type { UserDBInterface } from '../../interfaces/userDB';
5
+ import { ContentNavigator } from '../index';
6
+ import type { WeightedCard } from '../index';
7
+ import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
8
+ import type { CardGenerator, GeneratorContext } from './types';
9
+ import { logger } from '@db/util/logger';
10
10
 
11
11
  // ============================================================================
12
12
  // SRS NAVIGATOR
@@ -95,6 +95,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
95
95
  cardId: review.cardId,
96
96
  courseId: review.courseId,
97
97
  score,
98
+ reviewID: review._id,
98
99
  provenance: [
99
100
  {
100
101
  strategy: 'srs',
@@ -108,6 +109,8 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
108
109
  };
109
110
  });
110
111
 
112
+ logger.debug(`[srsNav] got ${scored.length} weighted cards`);
113
+
111
114
  // Sort by score descending and limit
112
115
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
113
116
  }
@@ -160,36 +163,4 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
160
163
 
161
164
  return { score, reason };
162
165
  }
163
-
164
- /**
165
- * Get pending reviews in legacy format.
166
- *
167
- * Returns all pending reviews for the course, enriched with session item fields.
168
- */
169
- async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
170
- if (!this.user || !this.course) {
171
- throw new Error('SRSNavigator requires user and course to be set');
172
- }
173
-
174
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
175
-
176
- return reviews.map((r) => ({
177
- ...r,
178
- contentSourceType: 'course' as const,
179
- contentSourceID: this.course!.getCourseID(),
180
- cardID: r.cardId,
181
- courseID: r.courseId,
182
- qualifiedID: `${r.courseId}-${r.cardId}`,
183
- reviewID: r._id,
184
- status: 'review' as const,
185
- }));
186
- }
187
-
188
- /**
189
- * SRS does not generate new cards.
190
- * Use ELONavigator or another generator for new cards.
191
- */
192
- async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
193
- return [];
194
- }
195
166
  }
@@ -9,7 +9,7 @@ import type { UserDBInterface } from '../../interfaces/userDB';
9
9
  // Generators produce candidate cards with initial scores.
10
10
  // They are the "source" stage of a navigation pipeline.
11
11
  //
12
- // Examples: ELO (skill proximity), SRS (review scheduling), HardcodedOrder
12
+ // Examples: ELO (skill proximity), SRS (review scheduling)
13
13
  //
14
14
  // Generators differ from filters:
15
15
  // - Generators: produce candidates from DB queries, assign initial scores
@@ -1,10 +1,4 @@
1
- import {
2
- StudyContentSource,
3
- UserDBInterface,
4
- CourseDBInterface,
5
- StudySessionReviewItem,
6
- StudySessionNewItem,
7
- } from '..';
1
+ import { StudyContentSource, UserDBInterface, CourseDBInterface } from '..';
8
2
 
9
3
  // Re-export filter types
10
4
  export type { CardFilter, FilterContext, CardFilterFactory } from './filters/types';
@@ -13,7 +7,6 @@ export type { CardFilter, FilterContext, CardFilterFactory } from './filters/typ
13
7
  export type { CardGenerator, GeneratorContext, CardGeneratorFactory } from './generators/types';
14
8
 
15
9
  import { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
16
- import { ScheduledCard } from '../types/user';
17
10
  import { logger } from '../../util/logger';
18
11
 
19
12
  // ============================================================================
@@ -33,7 +26,7 @@ import { logger } from '../../util/logger';
33
26
  // New code should use CardGenerator or CardFilter interfaces directly.
34
27
  //
35
28
  // 3. CardGenerator vs CardFilter:
36
- // - Generators (ELO, SRS, HardcodedOrder) produce candidate cards with scores
29
+ // - Generators (ELO, SRS) produce candidate cards with scores
37
30
  // - Filters (Hierarchy, Interference, Priority, EloDistance) transform scores
38
31
  //
39
32
  // 4. Pipeline architecture:
@@ -137,6 +130,17 @@ export interface WeightedCard {
137
130
  * First entry is from the generator, subsequent entries from filters.
138
131
  */
139
132
  provenance: StrategyContribution[];
133
+ /**
134
+ * Pre-fetched tags. Populated by Pipeline before filters run.
135
+ * Filters should use this instead of querying getAppliedTags() individually.
136
+ */
137
+ tags?: string[];
138
+ /**
139
+ * Review document ID (_id from ScheduledCard).
140
+ * Present when this card originated from SRS review scheduling.
141
+ * Used by SessionController to track review outcomes and maintain review state.
142
+ */
143
+ reviewID?: string;
140
144
  }
141
145
 
142
146
  /**
@@ -169,10 +173,10 @@ export function getCardOrigin(card: WeightedCard): 'new' | 'review' | 'failed' {
169
173
  export enum Navigators {
170
174
  ELO = 'elo',
171
175
  SRS = 'srs',
172
- HARDCODED = 'hardcodedOrder',
173
176
  HIERARCHY = 'hierarchyDefinition',
174
177
  INTERFERENCE = 'interferenceMitigator',
175
178
  RELATIVE_PRIORITY = 'relativePriority',
179
+ USER_TAG_PREFERENCE = 'userTagPreference',
176
180
  }
177
181
 
178
182
  // ============================================================================
@@ -180,7 +184,7 @@ export enum Navigators {
180
184
  // ============================================================================
181
185
  //
182
186
  // Navigators are classified as either generators or filters:
183
- // - Generators: Produce candidate cards (ELO, SRS, HardcodedOrder)
187
+ // - Generators: Produce candidate cards (ELO, SRS)
184
188
  // - Filters: Transform/score candidates (Hierarchy, Interference, RelativePriority)
185
189
  //
186
190
  // This classification is used by PipelineAssembler to build pipelines:
@@ -207,10 +211,10 @@ export enum NavigatorRole {
207
211
  export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
208
212
  [Navigators.ELO]: NavigatorRole.GENERATOR,
209
213
  [Navigators.SRS]: NavigatorRole.GENERATOR,
210
- [Navigators.HARDCODED]: NavigatorRole.GENERATOR,
211
214
  [Navigators.HIERARCHY]: NavigatorRole.FILTER,
212
215
  [Navigators.INTERFERENCE]: NavigatorRole.FILTER,
213
216
  [Navigators.RELATIVE_PRIORITY]: NavigatorRole.FILTER,
217
+ [Navigators.USER_TAG_PREFERENCE]: NavigatorRole.FILTER,
214
218
  };
215
219
 
216
220
  /**
@@ -245,10 +249,10 @@ export function isFilter(impl: string): boolean {
245
249
  */
246
250
  export abstract class ContentNavigator implements StudyContentSource {
247
251
  /** User interface for this navigation session */
248
- protected user?: UserDBInterface;
252
+ protected user: UserDBInterface;
249
253
 
250
254
  /** Course interface for this navigation session */
251
- protected course?: CourseDBInterface;
255
+ protected course: CourseDBInterface;
252
256
 
253
257
  /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
254
258
  protected strategyName?: string;
@@ -260,21 +264,74 @@ export abstract class ContentNavigator implements StudyContentSource {
260
264
  * Constructor for standard navigators.
261
265
  * Call this from subclass constructors to initialize common fields.
262
266
  *
263
- * Note: CompositeGenerator doesn't use this pattern and should call super() without args.
267
+ * Note: CompositeGenerator and Pipeline call super() without args, then set
268
+ * user/course fields directly if needed.
264
269
  */
265
270
  constructor(
266
271
  user?: UserDBInterface,
267
272
  course?: CourseDBInterface,
268
273
  strategyData?: ContentNavigationStrategyData
269
274
  ) {
270
- if (user && course && strategyData) {
271
- this.user = user;
272
- this.course = course;
275
+ this.user = user!;
276
+ this.course = course!;
277
+ if (strategyData) {
273
278
  this.strategyName = strategyData.name;
274
279
  this.strategyId = strategyData._id;
275
280
  }
276
281
  }
277
282
 
283
+ // ============================================================================
284
+ // STRATEGY STATE HELPERS
285
+ // ============================================================================
286
+ //
287
+ // These methods allow strategies to persist their own state (user preferences,
288
+ // learned patterns, temporal tracking) in the user database.
289
+ //
290
+ // ============================================================================
291
+
292
+ /**
293
+ * Unique key identifying this strategy for state storage.
294
+ *
295
+ * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
296
+ * Override in subclasses if multiple instances of the same strategy type
297
+ * need separate state storage.
298
+ */
299
+ protected get strategyKey(): string {
300
+ return this.constructor.name;
301
+ }
302
+
303
+ /**
304
+ * Get this strategy's persisted state for the current course.
305
+ *
306
+ * @returns The strategy's data payload, or null if no state exists
307
+ * @throws Error if user or course is not initialized
308
+ */
309
+ protected async getStrategyState<T>(): Promise<T | null> {
310
+ if (!this.user || !this.course) {
311
+ throw new Error(
312
+ `Cannot get strategy state: navigator not properly initialized. ` +
313
+ `Ensure user and course are provided to constructor.`
314
+ );
315
+ }
316
+ return this.user.getStrategyState<T>(this.course.getCourseID(), this.strategyKey);
317
+ }
318
+
319
+ /**
320
+ * Persist this strategy's state for the current course.
321
+ *
322
+ * @param data - The strategy's data payload to store
323
+ * @throws Error if user or course is not initialized
324
+ */
325
+ protected async putStrategyState<T>(data: T): Promise<void> {
326
+ if (!this.user || !this.course) {
327
+ throw new Error(
328
+ `Cannot put strategy state: navigator not properly initialized. ` +
329
+ `Ensure user and course are provided to constructor.`
330
+ );
331
+ }
332
+ return this.user.putStrategyState<T>(this.course.getCourseID(), this.strategyKey, data);
333
+ }
334
+
278
335
  /**
279
336
  * Factory method to create navigator instances dynamically.
280
337
  *
@@ -293,15 +350,19 @@ export abstract class ContentNavigator implements StudyContentSource {
293
350
 
294
351
  // Try different extension variations
295
352
  const variations = ['.ts', '.js', ''];
353
+ const dirs = ['filters', 'generators'];
296
354
 
297
355
  for (const ext of variations) {
298
- try {
299
- const module = await import(`./${implementingClass}${ext}`);
300
- NavigatorImpl = module.default;
301
- break; // Break the loop if loading succeeds
302
- } catch (e) {
303
- // Continue to next variation if this one fails
304
- logger.debug(`Failed to load with extension ${ext}:`, e);
356
+ for (const dir of dirs) {
357
+ const loadFrom = `./${dir}/${implementingClass}${ext}`;
358
+ try {
359
+ const module = await import(loadFrom);
360
+ NavigatorImpl = module.default;
361
+ break; // Break the loop if loading succeeds
362
+ } catch (e) {
363
+ // Continue to next variation if this one fails
364
+ logger.debug(`Failed to load extension from ${loadFrom}:`, e);
365
+ }
305
366
  }
306
367
  }
307
368
 
@@ -312,24 +373,6 @@ export abstract class ContentNavigator implements StudyContentSource {
312
373
  return new NavigatorImpl(user, course, strategyData);
313
374
  }
314
375
 
315
- /**
316
- * Get cards scheduled for review.
317
- *
318
- * @deprecated This method is part of the legacy StudyContentSource interface.
319
- * New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
320
- */
321
- abstract getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]>;
322
-
323
- /**
324
- * Get new cards for introduction.
325
- *
326
- * @deprecated This method is part of the legacy StudyContentSource interface.
327
- * New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
328
- *
329
- * @param n - Maximum number of new cards to return
330
- */
331
- abstract getNewCards(n?: number): Promise<StudySessionNewItem[]>;
332
-
333
376
  /**
334
377
  * Get cards with suitability scores and provenance trails.
335
378
  *
@@ -339,62 +382,23 @@ export abstract class ContentNavigator implements StudyContentSource {
339
382
  * better candidates for presentation. Each card includes a provenance trail
340
383
  * documenting how strategies contributed to the final score.
341
384
  *
385
+ * ## Implementation Required
386
+ * All navigation strategies MUST override this method. The base class does
387
+ * not provide a default implementation.
388
+ *
342
389
  * ## For Generators
343
390
  * Override this method to generate candidates and compute scores based on
344
391
  * your strategy's logic (e.g., ELO proximity, review urgency). Create the
345
392
  * initial provenance entry with action='generated'.
346
393
  *
347
- * ## Default Implementation
348
- * The base class provides a backward-compatible default that:
349
- * 1. Calls legacy getNewCards() and getPendingReviews()
350
- * 2. Assigns score=1.0 to all cards
351
- * 3. Creates minimal provenance from legacy methods
352
- * 4. Returns combined results up to limit
353
- *
354
- * This allows existing strategies to work without modification while
355
- * new strategies can override with proper scoring and provenance.
394
+ * ## For Filters
395
+ * Filters should implement the CardFilter interface instead and be composed
396
+ * via Pipeline. Filters do not directly implement getWeightedCards().
356
397
  *
357
398
  * @param limit - Maximum cards to return
358
399
  * @returns Cards sorted by score descending, with provenance trails
359
400
  */
360
- async getWeightedCards(limit: number): Promise<WeightedCard[]> {
361
- // Default implementation: delegate to legacy methods, assign score=1.0
362
- const newCards = await this.getNewCards(limit);
363
- const reviews = await this.getPendingReviews();
364
-
365
- const weighted: WeightedCard[] = [
366
- ...newCards.map((c) => ({
367
- cardId: c.cardID,
368
- courseId: c.courseID,
369
- score: 1.0,
370
- provenance: [
371
- {
372
- strategy: 'legacy',
373
- strategyName: this.strategyName || 'Legacy API',
374
- strategyId: this.strategyId || 'legacy-fallback',
375
- action: 'generated' as const,
376
- score: 1.0,
377
- reason: 'Generated via legacy getNewCards(), new card',
378
- },
379
- ],
380
- })),
381
- ...reviews.map((r) => ({
382
- cardId: r.cardID,
383
- courseId: r.courseID,
384
- score: 1.0,
385
- provenance: [
386
- {
387
- strategy: 'legacy',
388
- strategyName: this.strategyName || 'Legacy API',
389
- strategyId: this.strategyId || 'legacy-fallback',
390
- action: 'generated' as const,
391
- score: 1.0,
392
- reason: 'Generated via legacy getPendingReviews(), review',
393
- },
394
- ],
395
- })),
396
- ];
397
-
398
- return weighted.slice(0, limit);
401
+ async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
402
+ throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
399
403
  }
400
404
  }
@@ -0,0 +1,84 @@
1
+ import { DocType, DocTypePrefixes } from './types-legacy';
2
+
3
+ /**
4
+ * Template literal type for strategy state document IDs.
5
+ *
6
+ * Format: `STRATEGY_STATE-{courseId}-{strategyKey}`
7
+ */
8
+ export type StrategyStateId =
9
+ `${(typeof DocTypePrefixes)[DocType.STRATEGY_STATE]}::${string}::${string}`;
10
+
11
+ /**
12
+ * Document storing strategy-specific state in the user database.
13
+ *
14
+ * Each strategy can persist its own state (user preferences, learned patterns,
15
+ * temporal tracking, etc.) using this document type. The state is scoped to
16
+ * a (user, course, strategy) tuple.
17
+ *
18
+ * ## Use Cases
19
+ *
20
+ * 1. **Explicit user preferences**: User configures tag filters, difficulty
21
+ * preferences, or learning goals. UI writes to strategy state.
22
+ *
23
+ * 2. **Learned/temporal state**: Strategy tracks patterns over time, e.g.,
24
+ * "when did I last introduce confusable concepts together?"
25
+ *
26
+ * 3. **Adaptive personalization**: Strategy infers user preferences from
27
+ * behavior and stores them for future sessions.
28
+ *
29
+ * ## Storage Location
30
+ *
31
+ * These documents live in the **user database**, not the course database.
32
+ * They sync with the user's data across devices.
33
+ *
34
+ * ## Document ID Format
35
+ *
36
+ * `STRATEGY_STATE::{courseId}::{strategyKey}`
37
+ *
38
+ * Example: `STRATEGY_STATE::piano-basics::UserTagPreferenceFilter`
39
+ *
40
+ * @template T - The shape of the strategy-specific data payload
41
+ */
42
+ export interface StrategyStateDoc<T = unknown> {
43
+ _id: StrategyStateId;
44
+ _rev?: string;
45
+ docType: DocType.STRATEGY_STATE;
46
+
47
+ /**
48
+ * The course this state applies to.
49
+ */
50
+ courseId: string;
51
+
52
+ /**
53
+ * Unique key identifying the strategy instance.
54
+ * Typically the strategy class name (e.g., "UserTagPreferenceFilter",
55
+ * "InterferenceMitigatorNavigator").
56
+ *
57
+ * If a course has multiple instances of the same strategy type with
58
+ * different configurations, use a more specific key.
59
+ */
60
+ strategyKey: string;
61
+
62
+ /**
63
+ * Strategy-specific data payload.
64
+ * Each strategy defines its own schema for this field.
65
+ */
66
+ data: T;
67
+
68
+ /**
69
+ * ISO timestamp of last update.
70
+ * Use `moment.utc(updatedAt)` to parse into a Moment object.
71
+ */
72
+ updatedAt: string;
73
+ }
74
+
75
+ /**
76
+ * Build the document ID for a strategy state document.
77
+ *
78
+ * @param courseId - The course ID
79
+ * @param strategyKey - The strategy key (typically class name)
80
+ * @returns The document ID in format `STRATEGY_STATE::{courseId}::{strategyKey}`
81
+ */
82
+ export function buildStrategyStateId(courseId: string, strategyKey: string): StrategyStateId {
83
+ return `STRATEGY_STATE::${courseId}::${strategyKey}`;
84
+ }
@@ -19,6 +19,7 @@ export enum DocType {
19
19
  SCHEDULED_CARD = 'SCHEDULED_CARD',
20
20
  TAG = 'TAG',
21
21
  NAVIGATION_STRATEGY = 'NAVIGATION_STRATEGY',
22
+ STRATEGY_STATE = 'STRATEGY_STATE',
22
23
  }
23
24
 
24
25
  export interface QualifiedCardID {
@@ -103,6 +104,7 @@ export const DocTypePrefixes = {
103
104
  [DocType.VIEW]: 'VIEW',
104
105
  [DocType.PEDAGOGY]: 'PEDAGOGY',
105
106
  [DocType.NAVIGATION_STRATEGY]: 'NAVIGATION_STRATEGY',
107
+ [DocType.STRATEGY_STATE]: 'STRATEGY_STATE',
106
108
  } as const;
107
109
 
108
110
  export interface CardHistory<T extends CardRecord> {
@@ -1,4 +1,4 @@
1
- import { DocType, DocTypePrefixes } from '@db/core';
1
+ import { DocType, DocTypePrefixes, StrategyStateDoc, buildStrategyStateId } from '@db/core';
2
2
  import { getCardHistoryID } from '@db/core/util';
3
3
  import { CourseElo, Status } from '@vue-skuilder/common';
4
4
  import moment, { Moment } from 'moment';
@@ -1046,6 +1046,61 @@ Currently logged-in as ${this._username}.`
1046
1046
  public async updateUserElo(courseId: string, elo: CourseElo): Promise<PouchDB.Core.Response> {
1047
1047
  return updateUserElo(this._username, courseId, elo);
1048
1048
  }
1049
+
1050
+ public async getStrategyState<T>(courseId: string, strategyKey: string): Promise<T | null> {
1051
+ const docId = buildStrategyStateId(courseId, strategyKey);
1052
+ try {
1053
+ const doc = await this.localDB.get<StrategyStateDoc<T>>(docId);
1054
+ return doc.data;
1055
+ } catch (e) {
1056
+ const err = e as PouchError;
1057
+ if (err.status === 404) {
1058
+ return null;
1059
+ }
1060
+ throw e;
1061
+ }
1062
+ }
1063
+
1064
+ public async putStrategyState<T>(courseId: string, strategyKey: string, data: T): Promise<void> {
1065
+ const docId = buildStrategyStateId(courseId, strategyKey);
1066
+ let existingRev: string | undefined;
1067
+
1068
+ try {
1069
+ const existing = await this.localDB.get<StrategyStateDoc<T>>(docId);
1070
+ existingRev = existing._rev;
1071
+ } catch (e) {
1072
+ const err = e as PouchError;
1073
+ if (err.status !== 404) {
1074
+ throw e;
1075
+ }
1076
+ }
1077
+
1078
+ const doc: StrategyStateDoc<T> = {
1079
+ _id: docId,
1080
+ _rev: existingRev,
1081
+ docType: DocType.STRATEGY_STATE,
1082
+ courseId,
1083
+ strategyKey,
1084
+ data,
1085
+ updatedAt: new Date().toISOString(),
1086
+ };
1087
+
1088
+ await this.localDB.put(doc);
1089
+ }
1090
+
1091
+ public async deleteStrategyState(courseId: string, strategyKey: string): Promise<void> {
1092
+ const docId = buildStrategyStateId(courseId, strategyKey);
1093
+ try {
1094
+ const doc = await this.localDB.get(docId);
1095
+ await this.localDB.remove(doc);
1096
+ } catch (e) {
1097
+ const err = e as PouchError;
1098
+ if (err.status === 404) {
1099
+ return;
1100
+ }
1101
+ throw e;
1102
+ }
1103
+ }
1049
1104
  }
1050
1105
 
1051
1106
  export function accomodateGuest(): {
@@ -1056,7 +1111,9 @@ export function accomodateGuest(): {
1056
1111
 
1057
1112
  // Check if localStorage is available (browser environment)
1058
1113
  if (typeof localStorage === 'undefined') {
1059
- logger.log('[funnel] localStorage not available (Node.js environment), returning default guest');
1114
+ logger.log(
1115
+ '[funnel] localStorage not available (Node.js environment), returning default guest'
1116
+ );
1060
1117
  return {
1061
1118
  username: GuestUsername + 'nodejs-test',
1062
1119
  firstVisit: true,
@@ -1125,11 +1182,21 @@ export function accomodateGuest(): {
1125
1182
  bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 10
1126
1183
 
1127
1184
  const uuid = [
1128
- Array.from(bytes.slice(0, 4)).map(b => b.toString(16).padStart(2, '0')).join(''),
1129
- Array.from(bytes.slice(4, 6)).map(b => b.toString(16).padStart(2, '0')).join(''),
1130
- Array.from(bytes.slice(6, 8)).map(b => b.toString(16).padStart(2, '0')).join(''),
1131
- Array.from(bytes.slice(8, 10)).map(b => b.toString(16).padStart(2, '0')).join(''),
1132
- Array.from(bytes.slice(10, 16)).map(b => b.toString(16).padStart(2, '0')).join(''),
1185
+ Array.from(bytes.slice(0, 4))
1186
+ .map((b) => b.toString(16).padStart(2, '0'))
1187
+ .join(''),
1188
+ Array.from(bytes.slice(4, 6))
1189
+ .map((b) => b.toString(16).padStart(2, '0'))
1190
+ .join(''),
1191
+ Array.from(bytes.slice(6, 8))
1192
+ .map((b) => b.toString(16).padStart(2, '0'))
1193
+ .join(''),
1194
+ Array.from(bytes.slice(8, 10))
1195
+ .map((b) => b.toString(16).padStart(2, '0'))
1196
+ .join(''),
1197
+ Array.from(bytes.slice(10, 16))
1198
+ .map((b) => b.toString(16).padStart(2, '0'))
1199
+ .join(''),
1133
1200
  ].join('-');
1134
1201
 
1135
1202
  logger.log('[funnel] Generated UUID using crypto.getRandomValues():', uuid);
@@ -80,8 +80,7 @@ export class AdminDB implements AdminDBInterface {
80
80
  }
81
81
  }
82
82
 
83
- const dbs = await Promise.all(promisedCRDbs);
84
- return dbs.map((db) => {
83
+ return promisedCRDbs.map((db) => {
85
84
  return {
86
85
  ...db.getConfig(),
87
86
  _id: db._id,