@vue-skuilder/db 0.1.18 → 0.1.20

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 (59) hide show
  1. package/dist/{classroomDB-BgfrVb8d.d.ts → classroomDB-CZdMBiTU.d.ts} +71 -2
  2. package/dist/{classroomDB-CTOenngH.d.cts → classroomDB-PxDZTky3.d.cts} +71 -2
  3. package/dist/core/index.d.cts +80 -6
  4. package/dist/core/index.d.ts +80 -6
  5. package/dist/core/index.js +370 -52
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +369 -52
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +4 -3
  12. package/dist/impl/couch/index.d.ts +4 -3
  13. package/dist/impl/couch/index.js +371 -55
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +371 -55
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +5 -4
  18. package/dist/impl/static/index.d.ts +5 -4
  19. package/dist/impl/static/index.js +356 -44
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +356 -44
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/index.d.cts +10 -10
  26. package/dist/index.d.ts +10 -10
  27. package/dist/index.js +382 -55
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +381 -55
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
  32. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  33. package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/navigators-architecture.md +115 -10
  38. package/package.json +4 -4
  39. package/src/core/index.ts +1 -0
  40. package/src/core/interfaces/courseDB.ts +13 -0
  41. package/src/core/interfaces/userDB.ts +32 -0
  42. package/src/core/navigators/Pipeline.ts +127 -14
  43. package/src/core/navigators/filters/index.ts +3 -0
  44. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  45. package/src/core/navigators/hierarchyDefinition.ts +4 -4
  46. package/src/core/navigators/index.ts +59 -0
  47. package/src/core/navigators/inferredPreference.ts +107 -0
  48. package/src/core/navigators/interferenceMitigator.ts +1 -13
  49. package/src/core/navigators/relativePriority.ts +2 -14
  50. package/src/core/navigators/userGoal.ts +136 -0
  51. package/src/core/types/strategyState.ts +84 -0
  52. package/src/core/types/types-legacy.ts +2 -0
  53. package/src/impl/common/BaseUserDB.ts +74 -7
  54. package/src/impl/couch/adminDB.ts +1 -2
  55. package/src/impl/couch/courseDB.ts +30 -10
  56. package/src/impl/static/courseDB.ts +11 -0
  57. package/tests/core/navigators/Pipeline.test.ts +1 -0
  58. package/docs/todo-pipeline-optimization.md +0 -117
  59. package/docs/todo-strategy-state-storage.md +0 -278
@@ -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,
@@ -270,16 +270,6 @@ export class CourseDB implements StudyContentSource, CourseDBInterface {
270
270
  }
271
271
  });
272
272
 
273
- await Promise.all(
274
- cards.rows.map((r) => {
275
- return async () => {
276
- if (isSuccessRow(r)) {
277
- ret[r.id] = r.doc!.id_displayable_data;
278
- }
279
- };
280
- })
281
- );
282
-
283
273
  return ret;
284
274
  }
285
275
 
@@ -379,6 +369,36 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
379
369
  }
380
370
  }
381
371
 
372
+ async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
373
+ if (cardIds.length === 0) {
374
+ return new Map();
375
+ }
376
+
377
+ const db = getCourseDB(this.id);
378
+ const result = await db.query<TagStub>('getTags', {
379
+ keys: cardIds,
380
+ include_docs: false,
381
+ });
382
+
383
+ const tagsByCard = new Map<string, string[]>();
384
+
385
+ // Initialize all requested cards with empty arrays
386
+ for (const cardId of cardIds) {
387
+ tagsByCard.set(cardId, []);
388
+ }
389
+
390
+ // Populate from query results
391
+ for (const row of result.rows) {
392
+ const cardId = row.key as string;
393
+ const tagName = row.value?.name;
394
+ if (tagName && tagsByCard.has(cardId)) {
395
+ tagsByCard.get(cardId)!.push(tagName);
396
+ }
397
+ }
398
+
399
+ return tagsByCard;
400
+ }
401
+
382
402
  async addTagToCard(
383
403
  cardId: string,
384
404
  tagId: string,
@@ -249,6 +249,17 @@ export class StaticCourseDB implements CourseDBInterface {
249
249
  }
250
250
  }
251
251
 
252
+ async getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>> {
253
+ const tagsIndex = await this.unpacker.getTagsIndex();
254
+ const tagsByCard = new Map<string, string[]>();
255
+
256
+ for (const cardId of cardIds) {
257
+ tagsByCard.set(cardId, tagsIndex.byCard[cardId] || []);
258
+ }
259
+
260
+ return tagsByCard;
261
+ }
262
+
252
263
  async addTagToCard(_cardId: string, _tagId: string): Promise<PouchDB.Core.Response> {
253
264
  throw new Error('Cannot modify tags in static mode');
254
265
  }
@@ -102,6 +102,7 @@ function createMockContext(): { user: UserDBInterface; course: CourseDBInterface
102
102
 
103
103
  const mockCourse = {
104
104
  getCourseID: vi.fn().mockReturnValue('test-course'),
105
+ getAppliedTagsBatch: vi.fn().mockResolvedValue(new Map()),
105
106
  } as unknown as CourseDBInterface;
106
107
 
107
108
  return { user: mockUser, course: mockCourse };
@@ -1,117 +0,0 @@
1
- # TODO: Pipeline Optimization - Batch Tag Hydration
2
-
3
- ## Status: NOT STARTED
4
-
5
- ## Problem
6
-
7
- Each filter strategy independently queries for card tags, resulting in redundant database operations.
8
-
9
- For N cards through 3 filters = 3N tag lookups, when N would suffice.
10
-
11
- ```typescript
12
- // In HierarchyDefinitionNavigator
13
- const tagResponse = await context.course.getAppliedTags(card.cardId);
14
-
15
- // In InterferenceMitigatorNavigator
16
- const tagResponse = await context.course.getAppliedTags(card.cardId);
17
-
18
- // In RelativePriorityNavigator
19
- const tagResponse = await context.course.getAppliedTags(card.cardId);
20
- ```
21
-
22
- ## Proposed Solution: Hydrate Tags in WeightedCard
23
-
24
- Extend `WeightedCard` to optionally carry pre-fetched tag data:
25
-
26
- ```typescript
27
- interface WeightedCard {
28
- cardId: string;
29
- courseId: string;
30
- score: number;
31
- provenance: StrategyContribution[];
32
-
33
- /** Pre-fetched tags. If present, filters should use this instead of querying. */
34
- tags?: string[];
35
- }
36
- ```
37
-
38
- ### Implementation Steps
39
-
40
- #### Step 1: Add batch tag lookup method
41
-
42
- ```typescript
43
- // In CourseDBInterface
44
- getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
45
- ```
46
-
47
- #### Step 2: Update WeightedCard type
48
-
49
- Add optional `tags?: string[]` field to `WeightedCard` in `core/navigators/index.ts`.
50
-
51
- #### Step 3: Hydrate in Pipeline
52
-
53
- The `Pipeline` class should batch-fetch tags after getting candidates from the generator:
54
-
55
- ```typescript
56
- async getWeightedCards(limit: number): Promise<WeightedCard[]> {
57
- const context = await this.buildContext();
58
- let cards = await this.generator.getWeightedCards(fetchLimit, context);
59
-
60
- // Batch hydrate tags
61
- cards = await this.hydrateTags(cards);
62
-
63
- for (const filter of this.filters) {
64
- cards = await filter.transform(cards, context);
65
- }
66
-
67
- return cards.filter(c => c.score > 0)
68
- .sort((a, b) => b.score - a.score)
69
- .slice(0, limit);
70
- }
71
-
72
- private async hydrateTags(cards: WeightedCard[]): Promise<WeightedCard[]> {
73
- const cardIds = cards.map(c => c.cardId);
74
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
75
-
76
- return cards.map(c => ({
77
- ...c,
78
- tags: tagsByCard.get(c.cardId) ?? []
79
- }));
80
- }
81
- ```
82
-
83
- #### Step 4: Update filter strategies
84
-
85
- Each filter checks for pre-hydrated tags before querying:
86
-
87
- ```typescript
88
- const cardTags = card.tags ?? await this.getCardTags(card.cardId, context.course);
89
- ```
90
-
91
- #### Step 5: Add tests
92
-
93
- - Verify tags are populated by Pipeline
94
- - Verify filters use pre-fetched tags when available
95
- - Verify fallback works if tags missing
96
-
97
- ## Files to Modify
98
-
99
- | File | Change |
100
- |------|--------|
101
- | `core/navigators/index.ts` | Add `tags?` to `WeightedCard` |
102
- | `core/interfaces/courseDB.ts` | Add `getAppliedTagsBatch()` |
103
- | `impl/couch/courseDB.ts` | Implement `getAppliedTagsBatch()` |
104
- | `impl/static/courseDB.ts` | Implement `getAppliedTagsBatch()` |
105
- | `core/navigators/Pipeline.ts` | Add `hydrateTags()` step |
106
- | `core/navigators/hierarchyDefinition.ts` | Use `card.tags` if available |
107
- | `core/navigators/interferenceMitigator.ts` | Use `card.tags` if available |
108
- | `core/navigators/relativePriority.ts` | Use `card.tags` if available |
109
-
110
- ## Performance Expectations
111
-
112
- | Scenario | Before | After |
113
- |----------|--------|-------|
114
- | 20 cards, 3 filters | 60 tag queries | 1 batch query (20 cards) |
115
- | 50 cards, 4 filters | 200 tag queries | 1 batch query (50 cards) |
116
-
117
- Batch queries also reduce round-trip overhead compared to individual queries.
@@ -1,278 +0,0 @@
1
- # TODO: Strategy-Specific State Storage in UserDB
2
-
3
- ## Status: NOT STARTED
4
-
5
- ## Goal
6
-
7
- Enable NavigationStrategies (ContentNavigators) to persist their own state data in the
8
- user's database, allowing strategies to maintain context across sessions.
9
-
10
- ## Current State
11
-
12
- ### What Strategies Can Read
13
-
14
- | Data | Method | Notes |
15
- |------|--------|-------|
16
- | User's global ELO | `user.getCourseRegDoc(courseId).elo.global` | ✅ Available |
17
- | User's per-tag ELO | `user.getCourseRegDoc(courseId).elo.tags` | ✅ Available |
18
- | Seen cards | `user.getSeenCards(courseId)` | ✅ Card IDs only |
19
- | Active cards | `user.getActiveCards()` | ✅ Available |
20
- | Pending reviews | `user.getPendingReviews(courseId)` | ✅ ScheduledCard objects |
21
- | Card history | `user.putCardRecord()` returns `CardHistory` | 🟡 Only after write |
22
-
23
- ### What Strategies Cannot Do
24
-
25
- - **Store arbitrary state**: No namespaced storage for strategy-specific data
26
- - **Track temporal patterns**: No easy way to record "when did I last introduce tag X?"
27
- - **Persist learning context**: Strategy state is lost between sessions
28
-
29
- ## Use Cases
30
-
31
- ### 1. InterferenceMitigator
32
-
33
- **Need**: Track when interfering concepts were last introduced together.
34
-
35
- ```typescript
36
- // Desired: Store last introduction time per tag
37
- {
38
- "lastIntroduction": {
39
- "letter-b": "2024-01-15T10:30:00Z",
40
- "letter-d": "2024-01-16T14:20:00Z"
41
- }
42
- }
43
- ```
44
-
45
- ### 2. Minimal Pairs Strategy (Future)
46
-
47
- **Need**: Track discrimination training progress between confusable pairs.
48
-
49
- ```typescript
50
- // Desired: Store discrimination scores per pair
51
- {
52
- "pairScores": {
53
- "b-d": { "correct": 15, "total": 20, "lastPracticed": "..." },
54
- "m-n": { "correct": 8, "total": 10, "lastPracticed": "..." }
55
- }
56
- }
57
- ```
58
-
59
- ### 3. Adaptive Pacing Strategy (Future)
60
-
61
- **Need**: Track user's engagement patterns and optimal session timing.
62
-
63
- ```typescript
64
- // Desired: Store engagement metrics
65
- {
66
- "sessionMetrics": {
67
- "avgAccuracyByTimeOfDay": { "morning": 0.85, "afternoon": 0.78 },
68
- "optimalSessionLength": 180, // seconds
69
- "fatigueThreshold": 12 // cards before accuracy drops
70
- }
71
- }
72
- ```
73
-
74
- ## Proposed Solutions
75
-
76
- ### Option A: Extend CourseRegistration
77
-
78
- Add a `strategyState` field to the existing `CourseRegistration` document.
79
-
80
- **Schema:**
81
- ```typescript
82
- interface CourseRegistration {
83
- // ... existing fields ...
84
-
85
- /**
86
- * Strategy-specific state, keyed by strategy ID or type.
87
- * Each strategy owns its own namespace.
88
- */
89
- strategyState?: {
90
- [strategyKey: string]: unknown;
91
- };
92
- }
93
- ```
94
-
95
- **Pros:**
96
- - No new document types
97
- - Lives alongside other course-specific user data
98
- - Already synced via existing mechanisms
99
-
100
- **Cons:**
101
- - Potential for large documents if strategies store lots of data
102
- - All strategies share one document (contention on updates)
103
-
104
- ---
105
-
106
- ### Option B: Separate Strategy State Documents
107
-
108
- Create a new document type for strategy state.
109
-
110
- **Schema:**
111
- ```typescript
112
- interface StrategyStateDoc {
113
- _id: `STRATEGY_STATE-${courseId}-${strategyKey}`;
114
- docType: DocType.STRATEGY_STATE;
115
- courseId: string;
116
- strategyKey: string; // e.g., "interferenceMitigator" or strategy instance ID
117
- data: unknown;
118
- updatedAt: string;
119
- }
120
- ```
121
-
122
- **Pros:**
123
- - Clean separation
124
- - No document size concerns
125
- - Independent updates (no contention)
126
-
127
- **Cons:**
128
- - New document type to manage
129
- - More queries to fetch state
130
-
131
- ---
132
-
133
- ### Option C: Generic Key-Value Store in UserDB
134
-
135
- Add generic methods for namespaced storage.
136
-
137
- **Interface:**
138
- ```typescript
139
- interface UserDBWriter {
140
- // ... existing methods ...
141
-
142
- /**
143
- * Store data in a namespaced location.
144
- * @param namespace - Unique namespace (e.g., "strategy:interferenceMitigator:course123")
145
- * @param data - Arbitrary JSON-serializable data
146
- */
147
- putNamespacedData(namespace: string, data: unknown): Promise<void>;
148
-
149
- /**
150
- * Retrieve namespaced data.
151
- * @param namespace - The namespace to retrieve
152
- * @returns The stored data, or null if not found
153
- */
154
- getNamespacedData<T>(namespace: string): Promise<T | null>;
155
-
156
- /**
157
- * Delete namespaced data.
158
- * @param namespace - The namespace to delete
159
- */
160
- deleteNamespacedData(namespace: string): Promise<void>;
161
- }
162
- ```
163
-
164
- **Pros:**
165
- - Most flexible
166
- - Reusable beyond strategies
167
- - Clean API
168
-
169
- **Cons:**
170
- - Very generic (might be too permissive)
171
- - Namespace collision risk
172
-
173
- ---
174
-
175
- ## Recommended Approach
176
-
177
- **Option B (Separate Documents)** with a convenience wrapper:
178
-
179
- ```typescript
180
- // In ContentNavigator base class or a mixin
181
- abstract class ContentNavigator {
182
- // ... existing methods ...
183
-
184
- /**
185
- * Get this strategy's persisted state for the current course.
186
- */
187
- protected async getStrategyState<T>(): Promise<T | null> {
188
- const key = `STRATEGY_STATE-${this.course.getCourseID()}-${this.strategyKey}`;
189
- try {
190
- return await this.user.get<T>(key);
191
- } catch (e) {
192
- return null; // Not found
193
- }
194
- }
195
-
196
- /**
197
- * Persist this strategy's state for the current course.
198
- */
199
- protected async putStrategyState<T>(data: T): Promise<void> {
200
- const key = `STRATEGY_STATE-${this.course.getCourseID()}-${this.strategyKey}`;
201
- const existing = await this.getStrategyState<T>();
202
- await this.user.put({
203
- _id: key,
204
- _rev: existing?._rev,
205
- docType: DocType.STRATEGY_STATE,
206
- courseId: this.course.getCourseID(),
207
- strategyKey: this.strategyKey,
208
- data,
209
- updatedAt: new Date().toISOString(),
210
- });
211
- }
212
-
213
- /**
214
- * Unique key for this strategy instance.
215
- * Override in subclasses if multiple instances need separate state.
216
- */
217
- protected get strategyKey(): string {
218
- return this.constructor.name; // e.g., "InterferenceMitigatorNavigator"
219
- }
220
- }
221
- ```
222
-
223
- ## Implementation Plan
224
-
225
- ### Phase 1: Add DocType and Interface
226
-
227
- 1. Add `STRATEGY_STATE` to `DocType` enum in `packages/db/src/core/types/types-legacy.ts`
228
- 2. Define `StrategyStateDoc` interface
229
- 3. Add prefix to `DocTypePrefixes`
230
-
231
- ### Phase 2: Add UserDB Methods
232
-
233
- 1. Add `getStrategyState()` and `putStrategyState()` to `UserDBInterface`
234
- 2. Implement in `CouchUserDB`
235
-
236
- ### Phase 3: Add ContentNavigator Helpers
237
-
238
- 1. Add protected helper methods to `ContentNavigator` base class
239
- 2. Document usage pattern
240
-
241
- ### Phase 4: Update Strategies
242
-
243
- 1. Update `InterferenceMitigatorNavigator` to use state storage
244
- 2. Add tests for state persistence
245
-
246
- ## Files to Create/Modify
247
-
248
- | File | Action | Description |
249
- |------|--------|-------------|
250
- | `packages/db/src/core/types/types-legacy.ts` | MODIFY | Add STRATEGY_STATE DocType |
251
- | `packages/db/src/core/types/strategyState.ts` | CREATE | StrategyStateDoc interface |
252
- | `packages/db/src/core/interfaces/userDB.ts` | MODIFY | Add state storage methods |
253
- | `packages/db/src/impl/couch/userDB.ts` | MODIFY | Implement state storage |
254
- | `packages/db/src/core/navigators/index.ts` | MODIFY | Add helper methods to base class |
255
- | `packages/db/src/core/navigators/interferenceMitigator.ts` | MODIFY | Use state storage |
256
-
257
- ## Test Cases
258
-
259
- 1. **Store and retrieve**: Write state, read it back, verify equality
260
- 2. **Update existing**: Write state, update it, verify new value
261
- 3. **Separate namespaces**: Two strategies store data, each gets their own
262
- 4. **Cross-session persistence**: Store state, simulate new session, verify data persists
263
- 5. **Missing state**: Read state that doesn't exist, get null (not error)
264
-
265
- ## Open Questions
266
-
267
- 1. **State migration**: How to handle strategy state when strategy config changes?
268
- 2. **State cleanup**: When a strategy is deleted, should its state be cleaned up?
269
- 3. **State size limits**: Should we enforce maximum state size?
270
- 4. **Sync behavior**: How does state sync across devices for multi-device users?
271
-
272
- ## Related Files
273
-
274
- - `packages/db/src/core/interfaces/userDB.ts` — UserDB interface
275
- - `packages/db/src/impl/couch/userDB.ts` — UserDB implementation
276
- - `packages/db/src/core/navigators/index.ts` — ContentNavigator base class
277
- - `packages/db/src/core/types/types-legacy.ts` — DocType enum
278
- - `packages/db/docs/navigators-architecture.md` — Architecture overview