@vue-skuilder/db 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.26",
7
+ "version": "0.1.27",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.26",
51
+ "@vue-skuilder/common": "0.1.27",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^7.0.0",
63
63
  "vitest": "^4.0.15"
64
64
  },
65
- "stableVersion": "0.1.26"
65
+ "stableVersion": "0.1.27"
66
66
  }
@@ -0,0 +1,466 @@
1
+ import { logger } from '../util/logger';
2
+ import { getDataLayer } from '../factory';
3
+ import { DocType, DocTypePrefixes } from './types/types-legacy';
4
+ import { filterAllDocsByPrefix } from '../impl/common/userDBHelpers';
5
+ import type { UserDBInterface } from './interfaces/userDB';
6
+ import type { ScheduledCard, CourseRegistration } from './types/user';
7
+
8
+ // ============================================================================
9
+ // USER DATABASE DEBUGGER
10
+ // ============================================================================
11
+ //
12
+ // Console-accessible debug API for inspecting user database (PouchDB/CouchDB).
13
+ //
14
+ // Exposed as `window.skuilder.userdb` for interactive exploration.
15
+ //
16
+ // Usage:
17
+ // window.skuilder.userdb.showUser()
18
+ // window.skuilder.userdb.showScheduledReviews()
19
+ // window.skuilder.userdb.showCourseRegistrations()
20
+ // window.skuilder.userdb.showCardHistory('cardId')
21
+ // window.skuilder.userdb.queryByType('SCHEDULED_CARD')
22
+ // window.skuilder.userdb.dbInfo()
23
+ // window.skuilder.userdb.export()
24
+ //
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Get the user database instance safely
29
+ */
30
+ function getUserDB(): UserDBInterface | null {
31
+ try {
32
+ const provider = getDataLayer();
33
+ return provider.getUserDB();
34
+ } catch {
35
+ logger.info('[UserDB Debug] Data layer not initialized yet.');
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get raw PouchDB instance for advanced queries
42
+ * This accesses the internal localDB property
43
+ */
44
+ function getRawDB(): PouchDB.Database | null {
45
+ const userDB = getUserDB();
46
+ if (!userDB) return null;
47
+
48
+ // Access the internal localDB property
49
+ // This is a bit of a hack but necessary for raw queries
50
+ const rawDB = (userDB as any).localDB;
51
+ if (!rawDB) {
52
+ logger.info('[UserDB Debug] Unable to access raw database instance.');
53
+ return null;
54
+ }
55
+
56
+ return rawDB;
57
+ }
58
+
59
+ /**
60
+ * Format a timestamp for display
61
+ */
62
+ function formatTimestamp(isoString: string): string {
63
+ const date = new Date(isoString);
64
+ return date.toLocaleString();
65
+ }
66
+
67
+ /**
68
+ * Console API object exposed on window.skuilder.userdb
69
+ */
70
+ export const userDBDebugAPI = {
71
+ /**
72
+ * Show current user information
73
+ */
74
+ showUser(): void {
75
+ const userDB = getUserDB();
76
+ if (!userDB) return;
77
+
78
+ // eslint-disable-next-line no-console
79
+ console.group('👤 User Information');
80
+ logger.info(`Username: ${userDB.getUsername()}`);
81
+ logger.info(`Logged in: ${userDB.isLoggedIn() ? 'Yes ✅' : 'No (Guest) ❌'}`);
82
+
83
+ userDB.getConfig()
84
+ .then((config) => {
85
+ logger.info('Configuration:');
86
+ logger.info(JSON.stringify(config, null, 2));
87
+ })
88
+ .catch((err) => {
89
+ logger.info(`Error loading config: ${err.message}`);
90
+ })
91
+ .finally(() => {
92
+ // eslint-disable-next-line no-console
93
+ console.groupEnd();
94
+ });
95
+ },
96
+
97
+ /**
98
+ * Show scheduled reviews
99
+ */
100
+ async showScheduledReviews(courseId?: string): Promise<void> {
101
+ const userDB = getUserDB();
102
+ if (!userDB) return;
103
+
104
+ try {
105
+ const reviews = await userDB.getPendingReviews(courseId);
106
+
107
+ // eslint-disable-next-line no-console
108
+ console.group(`📅 Scheduled Reviews${courseId ? ` (${courseId})` : ''}`);
109
+ logger.info(`Total: ${reviews.length}`);
110
+
111
+ if (reviews.length > 0) {
112
+ // Group by course
113
+ const byCourse = new Map<string, ScheduledCard[]>();
114
+ for (const review of reviews) {
115
+ if (!byCourse.has(review.courseId)) {
116
+ byCourse.set(review.courseId, []);
117
+ }
118
+ byCourse.get(review.courseId)!.push(review);
119
+ }
120
+
121
+ for (const [course, courseReviews] of byCourse) {
122
+ // eslint-disable-next-line no-console
123
+ console.group(`Course: ${course} (${courseReviews.length} reviews)`);
124
+
125
+ // Sort by review time
126
+ const sorted = courseReviews.sort((a, b) => {
127
+ const timeA = typeof a.reviewTime === 'string' ? a.reviewTime : a.reviewTime.toISOString();
128
+ const timeB = typeof b.reviewTime === 'string' ? b.reviewTime : b.reviewTime.toISOString();
129
+ return new Date(timeA).getTime() - new Date(timeB).getTime();
130
+ });
131
+
132
+ // Show first 10
133
+ for (const review of sorted.slice(0, 10)) {
134
+ const reviewTimeStr = typeof review.reviewTime === 'string'
135
+ ? review.reviewTime
136
+ : review.reviewTime.toISOString();
137
+ logger.info(
138
+ ` ${review.cardId.slice(0, 12)}... @ ${formatTimestamp(reviewTimeStr)} ` +
139
+ `[${review.scheduledFor}/${review.schedulingAgentId}]`
140
+ );
141
+ }
142
+
143
+ if (sorted.length > 10) {
144
+ logger.info(` ... and ${sorted.length - 10} more`);
145
+ }
146
+
147
+ // eslint-disable-next-line no-console
148
+ console.groupEnd();
149
+ }
150
+ }
151
+
152
+ // eslint-disable-next-line no-console
153
+ console.groupEnd();
154
+ } catch (err: any) {
155
+ logger.info(`Error loading scheduled reviews: ${err.message}`);
156
+ }
157
+ },
158
+
159
+ /**
160
+ * Show course registrations
161
+ */
162
+ async showCourseRegistrations(): Promise<void> {
163
+ const userDB = getUserDB();
164
+ if (!userDB) return;
165
+
166
+ try {
167
+ const registrations = await userDB.getActiveCourses();
168
+
169
+ // eslint-disable-next-line no-console
170
+ console.group('📚 Course Registrations');
171
+ logger.info(`Total: ${registrations.length}`);
172
+
173
+ if (registrations.length > 0) {
174
+ // eslint-disable-next-line no-console
175
+ console.table(
176
+ registrations.map((reg: CourseRegistration) => ({
177
+ courseId: reg.courseID,
178
+ status: reg.status || 'active',
179
+ elo: typeof reg.elo === 'number'
180
+ ? reg.elo.toFixed(0)
181
+ : reg.elo?.global?.score?.toFixed(0) || 'N/A',
182
+ }))
183
+ );
184
+ }
185
+
186
+ // eslint-disable-next-line no-console
187
+ console.groupEnd();
188
+ } catch (err: any) {
189
+ logger.info(`Error loading course registrations: ${err.message}`);
190
+ }
191
+ },
192
+
193
+ /**
194
+ * Show card history for a specific card
195
+ */
196
+ async showCardHistory(cardId: string): Promise<void> {
197
+ const rawDB = getRawDB();
198
+ if (!rawDB) return;
199
+
200
+ try {
201
+ // Card history docs use prefix 'cardH'
202
+ const result = await filterAllDocsByPrefix(rawDB, DocTypePrefixes[DocType.CARDRECORD]);
203
+
204
+ // Filter for this specific card
205
+ const cardHistories = result.rows
206
+ .filter((row: any) => row.doc && row.doc.cardID === cardId)
207
+ .map((row: any) => row.doc);
208
+
209
+ // eslint-disable-next-line no-console
210
+ console.group(`🎴 Card History: ${cardId}`);
211
+ logger.info(`Total interactions: ${cardHistories.length}`);
212
+
213
+ if (cardHistories.length > 0) {
214
+ // Sort by timestamp
215
+ const sorted = cardHistories.sort((a: any, b: any) =>
216
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
217
+ );
218
+
219
+ // Show recent history
220
+ // eslint-disable-next-line no-console
221
+ console.table(
222
+ sorted.slice(0, 20).map((doc: any) => ({
223
+ time: formatTimestamp(doc.timestamp),
224
+ outcome: doc.outcome || 'N/A',
225
+ duration: doc.duration ? `${(doc.duration / 1000).toFixed(1)}s` : 'N/A',
226
+ courseId: doc.courseId,
227
+ }))
228
+ );
229
+
230
+ if (sorted.length > 20) {
231
+ logger.info(`... and ${sorted.length - 20} more interactions`);
232
+ }
233
+ }
234
+
235
+ // eslint-disable-next-line no-console
236
+ console.groupEnd();
237
+ } catch (err: any) {
238
+ logger.info(`Error loading card history: ${err.message}`);
239
+ }
240
+ },
241
+
242
+ /**
243
+ * Query documents by type
244
+ */
245
+ async queryByType(docType: keyof typeof DocType, limit: number = 50): Promise<void> {
246
+ const rawDB = getRawDB();
247
+ if (!rawDB) return;
248
+
249
+ try {
250
+ const prefix = DocTypePrefixes[DocType[docType]];
251
+ if (!prefix) {
252
+ logger.info(`Unknown document type: ${docType}`);
253
+ return;
254
+ }
255
+
256
+ const result = await filterAllDocsByPrefix(rawDB, prefix);
257
+
258
+ // eslint-disable-next-line no-console
259
+ console.group(`📄 Documents: ${docType}`);
260
+ logger.info(`Total: ${result.rows.length}`);
261
+ logger.info(`Prefix: ${prefix}`);
262
+
263
+ if (result.rows.length > 0) {
264
+ logger.info('Sample documents:');
265
+ const samples = result.rows.slice(0, Math.min(limit, result.rows.length));
266
+
267
+ for (const row of samples) {
268
+ logger.info(`\n${row.id}:`);
269
+ logger.info(JSON.stringify(row.doc, null, 2));
270
+ }
271
+
272
+ if (result.rows.length > limit) {
273
+ logger.info(`\n... and ${result.rows.length - limit} more documents`);
274
+ }
275
+ }
276
+
277
+ // eslint-disable-next-line no-console
278
+ console.groupEnd();
279
+ } catch (err: any) {
280
+ logger.info(`Error querying documents: ${err.message}`);
281
+ }
282
+ },
283
+
284
+ /**
285
+ * Show database info and statistics
286
+ */
287
+ async dbInfo(): Promise<void> {
288
+ const rawDB = getRawDB();
289
+ if (!rawDB) return;
290
+
291
+ try {
292
+ const info = await rawDB.info();
293
+
294
+ // eslint-disable-next-line no-console
295
+ console.group('ℹ️ Database Information');
296
+ logger.info(`Database name: ${info.db_name}`);
297
+ logger.info(`Total documents: ${info.doc_count}`);
298
+ logger.info(`Update sequence: ${info.update_seq}`);
299
+ // disk_size may not be available in all PouchDB implementations
300
+ if ('disk_size' in info) {
301
+ logger.info(`Disk size: ${((info as any).disk_size || 0) / 1024 / 1024} MB`);
302
+ }
303
+
304
+ // Count documents by type
305
+ logger.info('\nDocument counts by type:');
306
+ const allDocs = await rawDB.allDocs({ include_docs: false });
307
+ const typeCounts = new Map<string, number>();
308
+
309
+ for (const row of allDocs.rows) {
310
+ // Extract prefix from document ID
311
+ let prefix = 'other';
312
+ for (const [type, typePrefix] of Object.entries(DocTypePrefixes)) {
313
+ if (row.id.startsWith(typePrefix)) {
314
+ prefix = type;
315
+ break;
316
+ }
317
+ }
318
+ typeCounts.set(prefix, (typeCounts.get(prefix) || 0) + 1);
319
+ }
320
+
321
+ // eslint-disable-next-line no-console
322
+ console.table(
323
+ Array.from(typeCounts.entries())
324
+ .sort((a, b) => b[1] - a[1])
325
+ .map(([type, count]) => ({ type, count }))
326
+ );
327
+
328
+ // eslint-disable-next-line no-console
329
+ console.groupEnd();
330
+ } catch (err: any) {
331
+ logger.info(`Error getting database info: ${err.message}`);
332
+ }
333
+ },
334
+
335
+ /**
336
+ * List all document types
337
+ */
338
+ listDocTypes(): void {
339
+ // eslint-disable-next-line no-console
340
+ console.group('📋 Available Document Types');
341
+ logger.info('Use with queryByType(type):');
342
+
343
+ for (const [type, prefix] of Object.entries(DocTypePrefixes)) {
344
+ logger.info(` ${type.padEnd(30)} → prefix: "${prefix}"`);
345
+ }
346
+
347
+ // eslint-disable-next-line no-console
348
+ console.groupEnd();
349
+ },
350
+
351
+ /**
352
+ * Export database contents (limited, for debugging)
353
+ */
354
+ async export(includeContent: boolean = false): Promise<string> {
355
+ const rawDB = getRawDB();
356
+ const userDB = getUserDB();
357
+ if (!rawDB || !userDB) return '{}';
358
+
359
+ try {
360
+ const data: any = {
361
+ username: userDB.getUsername(),
362
+ loggedIn: userDB.isLoggedIn(),
363
+ timestamp: new Date().toISOString(),
364
+ };
365
+
366
+ if (includeContent) {
367
+ // Get all documents
368
+ const allDocs = await rawDB.allDocs({ include_docs: true });
369
+ data.documents = allDocs.rows.map((row: any) => ({
370
+ id: row.id,
371
+ doc: row.doc,
372
+ }));
373
+ data.totalDocs = allDocs.rows.length;
374
+ } else {
375
+ // Just get counts
376
+ const allDocs = await rawDB.allDocs({ include_docs: false });
377
+ data.totalDocs = allDocs.rows.length;
378
+
379
+ const typeCounts = new Map<string, number>();
380
+ for (const row of allDocs.rows) {
381
+ let prefix = 'other';
382
+ for (const [type, typePrefix] of Object.entries(DocTypePrefixes)) {
383
+ if (row.id.startsWith(typePrefix)) {
384
+ prefix = type;
385
+ break;
386
+ }
387
+ }
388
+ typeCounts.set(prefix, (typeCounts.get(prefix) || 0) + 1);
389
+ }
390
+ data.docCounts = Object.fromEntries(typeCounts);
391
+ }
392
+
393
+ const json = JSON.stringify(data, null, 2);
394
+ logger.info('[UserDB Debug] Database info exported. Copy the returned string or use:');
395
+ logger.info(' copy(window.skuilder.userdb.export())');
396
+ if (!includeContent) {
397
+ logger.info(' For full content export: window.skuilder.userdb.export(true)');
398
+ }
399
+ return json;
400
+ } catch (err: any) {
401
+ logger.info(`Error exporting database: ${err.message}`);
402
+ return '{}';
403
+ }
404
+ },
405
+
406
+ /**
407
+ * Execute raw PouchDB query
408
+ */
409
+ async raw(queryFn: (db: PouchDB.Database) => Promise<any>): Promise<void> {
410
+ const rawDB = getRawDB();
411
+ if (!rawDB) return;
412
+
413
+ try {
414
+ const result = await queryFn(rawDB);
415
+ logger.info('[UserDB Debug] Query result:');
416
+ logger.info(result);
417
+ } catch (err: any) {
418
+ logger.info(`[UserDB Debug] Query error: ${err.message}`);
419
+ }
420
+ },
421
+
422
+ /**
423
+ * Show help
424
+ */
425
+ help(): void {
426
+ logger.info(`
427
+ 🔧 UserDB Debug API
428
+
429
+ Commands:
430
+ .showUser() Show current user info and config
431
+ .showScheduledReviews(courseId?) Show scheduled reviews (optionally filter by course)
432
+ .showCourseRegistrations() Show all course registrations
433
+ .showCardHistory(cardId) Show interaction history for a card
434
+ .queryByType(docType, limit?) Query documents by type (e.g., 'SCHEDULED_CARD')
435
+ .listDocTypes() List all available document types
436
+ .dbInfo() Show database info and statistics
437
+ .export(includeContent?) Export database info (true = include all docs)
438
+ .raw(queryFn) Execute raw PouchDB query
439
+ .help() Show this help message
440
+
441
+ Examples:
442
+ window.skuilder.userdb.showUser()
443
+ window.skuilder.userdb.showScheduledReviews('course123')
444
+ window.skuilder.userdb.queryByType('SCHEDULED_CARD', 10)
445
+ window.skuilder.userdb.raw(db => db.allDocs({ limit: 5 }))
446
+ `);
447
+ },
448
+ };
449
+
450
+ // ============================================================================
451
+ // WINDOW MOUNT
452
+ // ============================================================================
453
+
454
+ /**
455
+ * Mount the debug API on window.skuilder.userdb
456
+ */
457
+ export function mountUserDBDebugger(): void {
458
+ if (typeof window === 'undefined') return;
459
+
460
+ const win = window as any;
461
+ win.skuilder = win.skuilder || {};
462
+ win.skuilder.userdb = userDBDebugAPI;
463
+ }
464
+
465
+ // Auto-mount when module is loaded
466
+ mountUserDBDebugger();
package/src/core/index.ts CHANGED
@@ -10,3 +10,6 @@ export * from './util';
10
10
  export * from './navigators';
11
11
  export * from './bulkImport';
12
12
  export * from './orchestration';
13
+
14
+ // Export debug APIs
15
+ export { userDBDebugAPI, mountUserDBDebugger } from './UserDBDebugger';
@@ -38,7 +38,7 @@ const DEFAULT_MIN_COUNT = 3;
38
38
  * A filter strategy that gates cards based on prerequisite mastery.
39
39
  *
40
40
  * Cards are locked until the user masters all prerequisite tags.
41
- * Locked cards receive score: 0 (hard filter).
41
+ * Locked cards receive score * 0.01 (strong penalty, not hard filter).
42
42
  *
43
43
  * Mastery is determined by:
44
44
  * - User's ELO for the tag exceeds threshold (or avgElo if not specified)
@@ -198,7 +198,7 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
198
198
  /**
199
199
  * CardFilter.transform implementation.
200
200
  *
201
- * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
201
+ * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
202
202
  */
203
203
  async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
204
204
  // Get mastery state
@@ -215,7 +215,8 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
215
215
  unlockedTags,
216
216
  masteredTags
217
217
  );
218
- const finalScore = isUnlocked ? card.score : 0;
218
+ const LOCKED_PENALTY = 0.01;
219
+ const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
219
220
  const action = isUnlocked ? 'passed' : 'penalized';
220
221
 
221
222
  gated.push({