@vue-skuilder/db 0.1.32-b → 0.1.32-c

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 (50) hide show
  1. package/dist/core/index.d.cts +16 -12
  2. package/dist/core/index.d.ts +16 -12
  3. package/dist/core/index.js +2262 -223
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +2239 -196
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
  8. package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
  9. package/dist/impl/couch/index.d.cts +17 -4
  10. package/dist/impl/couch/index.d.ts +17 -4
  11. package/dist/impl/couch/index.js +2306 -220
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +2294 -204
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.cts +4 -5
  16. package/dist/impl/static/index.d.ts +4 -5
  17. package/dist/impl/static/index.js +2266 -227
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +2251 -208
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
  22. package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
  23. package/dist/index.d.cts +9 -444
  24. package/dist/index.d.ts +9 -444
  25. package/dist/index.js +9637 -8931
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +9539 -8833
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
  30. package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
  31. package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
  32. package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
  33. package/dist/util/packer/index.d.cts +3 -3
  34. package/dist/util/packer/index.d.ts +3 -3
  35. package/docs/navigators-architecture.md +2 -2
  36. package/package.json +2 -2
  37. package/src/core/interfaces/contentSource.ts +2 -1
  38. package/src/core/navigators/Pipeline.ts +47 -29
  39. package/src/core/navigators/PipelineDebugger.ts +49 -1
  40. package/src/core/navigators/filters/hierarchyDefinition.ts +88 -5
  41. package/src/core/navigators/generators/prescribed.ts +618 -43
  42. package/src/core/navigators/index.ts +2 -1
  43. package/src/impl/couch/CourseSyncService.ts +72 -4
  44. package/src/impl/couch/courseDB.ts +3 -2
  45. package/src/impl/static/courseDB.ts +3 -2
  46. package/src/study/SessionController.ts +79 -9
  47. package/src/study/services/EloService.ts +22 -3
  48. package/src/study/services/ResponseProcessor.ts +7 -3
  49. package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
  50. package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
@@ -652,13 +652,20 @@ function captureRun(report) {
652
652
  runHistory.pop();
653
653
  }
654
654
  }
655
- function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
655
+ function parseCardElo(provenance) {
656
+ const eloEntry = provenance.find((p) => p.strategy === "elo");
657
+ if (!eloEntry?.reason) return void 0;
658
+ const match = eloEntry.reason.match(/card:\s*(\d+)/);
659
+ return match ? parseInt(match[1], 10) : void 0;
660
+ }
661
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
656
662
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
657
663
  const cards = allCards.map((card) => ({
658
664
  cardId: card.cardId,
659
665
  courseId: card.courseId,
660
666
  origin: getOrigin(card),
661
667
  finalScore: card.score,
668
+ cardElo: parseCardElo(card.provenance),
662
669
  provenance: card.provenance,
663
670
  tags: card.tags,
664
671
  selected: selectedIds.has(card.cardId)
@@ -668,6 +675,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
668
675
  return {
669
676
  courseId,
670
677
  courseName,
678
+ userElo,
671
679
  generatorName,
672
680
  generators,
673
681
  generatedCount,
@@ -688,6 +696,7 @@ function printRunSummary(run) {
688
696
  console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
689
697
  logger.info(`Run ID: ${run.runId}`);
690
698
  logger.info(`Time: ${run.timestamp.toISOString()}`);
699
+ logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
691
700
  logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
692
701
  if (run.generators && run.generators.length > 0) {
693
702
  console.group("Generator breakdown:");
@@ -774,8 +783,12 @@ var init_PipelineDebugger = __esm({
774
783
  console.group(`\u{1F3B4} Card: ${cardId}`);
775
784
  logger.info(`Course: ${card.courseId}`);
776
785
  logger.info(`Origin: ${card.origin}`);
786
+ logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
777
787
  logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
778
788
  logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
789
+ if (card.tags && card.tags.length > 0) {
790
+ logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
791
+ }
779
792
  logger.info("Provenance:");
780
793
  logger.info(formatProvenance(card.provenance));
781
794
  console.groupEnd();
@@ -939,6 +952,27 @@ var init_PipelineDebugger = __esm({
939
952
  }
940
953
  return _activePipeline.diagnoseCardSpace({ threshold });
941
954
  },
955
+ /**
956
+ * Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
957
+ *
958
+ * @param tagFilter - Optional glob pattern(s) to filter tags.
959
+ * Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
960
+ */
961
+ async showTagElo(tagFilter) {
962
+ if (!_activePipeline) {
963
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
964
+ return;
965
+ }
966
+ const status = await _activePipeline.getTagEloStatus(tagFilter);
967
+ const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
968
+ if (entries.length === 0) {
969
+ logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
970
+ return;
971
+ }
972
+ console.table(
973
+ Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
974
+ );
975
+ },
942
976
  /**
943
977
  * Show help.
944
978
  */
@@ -950,6 +984,7 @@ Commands:
950
984
  .showLastRun() Show summary of most recent pipeline run
951
985
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
952
986
  .showCard(cardId) Show provenance trail for a specific card
987
+ .showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
953
988
  .explainReviews() Analyze why reviews were/weren't selected
954
989
  .diagnoseCardSpace() Scan full card space through filters (async)
955
990
  .showRegistry() Show navigator registry (classes + roles)
@@ -1263,60 +1298,423 @@ var prescribed_exports = {};
1263
1298
  __export(prescribed_exports, {
1264
1299
  default: () => PrescribedCardsGenerator
1265
1300
  });
1266
- var PrescribedCardsGenerator;
1301
+ function dedupe(arr) {
1302
+ return [...new Set(arr)];
1303
+ }
1304
+ function isoNow() {
1305
+ return (/* @__PURE__ */ new Date()).toISOString();
1306
+ }
1307
+ function clamp(value, min, max) {
1308
+ return Math.max(min, Math.min(max, value));
1309
+ }
1310
+ function matchesTagPattern(tag, pattern) {
1311
+ if (pattern === "*") return true;
1312
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1313
+ const re = new RegExp(`^${escaped}$`);
1314
+ return re.test(tag);
1315
+ }
1316
+ function pickTopByScore(cards, limit) {
1317
+ return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1318
+ }
1319
+ var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, LOCKED_TAG_PREFIXES, LESSON_GATE_PENALTY_TAG_HINT, PrescribedCardsGenerator;
1267
1320
  var init_prescribed = __esm({
1268
1321
  "src/core/navigators/generators/prescribed.ts"() {
1269
1322
  "use strict";
1270
1323
  init_navigators();
1271
1324
  init_logger();
1325
+ DEFAULT_FRESHNESS_WINDOW = 3;
1326
+ DEFAULT_MAX_DIRECT_PER_RUN = 3;
1327
+ DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1328
+ DEFAULT_HIERARCHY_DEPTH = 2;
1329
+ DEFAULT_MIN_COUNT = 3;
1330
+ BASE_TARGET_SCORE = 1;
1331
+ BASE_SUPPORT_SCORE = 0.8;
1332
+ MAX_TARGET_MULTIPLIER = 8;
1333
+ MAX_SUPPORT_MULTIPLIER = 4;
1334
+ LOCKED_TAG_PREFIXES = ["concept:"];
1335
+ LESSON_GATE_PENALTY_TAG_HINT = "concept:";
1272
1336
  PrescribedCardsGenerator = class extends ContentNavigator {
1273
1337
  name;
1274
1338
  config;
1275
1339
  constructor(user, course, strategyData) {
1276
1340
  super(user, course, strategyData);
1277
1341
  this.name = strategyData.name || "Prescribed Cards";
1278
- try {
1279
- const parsed = JSON.parse(strategyData.serializedData);
1280
- this.config = { cardIds: parsed.cardIds || [] };
1281
- } catch {
1282
- this.config = { cardIds: [] };
1283
- }
1342
+ this.config = this.parseConfig(strategyData.serializedData);
1284
1343
  logger.debug(
1285
- `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1344
+ `[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
1286
1345
  );
1287
1346
  }
1288
- async getWeightedCards(limit, _context) {
1289
- if (this.config.cardIds.length === 0) {
1347
+ get strategyKey() {
1348
+ return "PrescribedProgress";
1349
+ }
1350
+ async getWeightedCards(limit, context) {
1351
+ if (this.config.groups.length === 0 || limit <= 0) {
1290
1352
  return [];
1291
1353
  }
1292
1354
  const courseId = this.course.getCourseID();
1293
1355
  const activeCards = await this.user.getActiveCards();
1294
1356
  const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1295
- const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1296
- if (eligibleIds.length === 0) {
1297
- logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1357
+ const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
1358
+ const seenIds = new Set(seenCards);
1359
+ const progress = await this.getStrategyState() ?? {
1360
+ updatedAt: isoNow(),
1361
+ groups: {}
1362
+ };
1363
+ const hierarchyConfigs = await this.loadHierarchyConfigs();
1364
+ const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
1365
+ const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
1366
+ const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
1367
+ const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
1368
+ const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
1369
+ const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
1370
+ const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
1371
+ const nextState = {
1372
+ updatedAt: isoNow(),
1373
+ groups: {}
1374
+ };
1375
+ const emitted = [];
1376
+ const emittedIds = /* @__PURE__ */ new Set();
1377
+ for (const group of this.config.groups) {
1378
+ const runtime = this.buildGroupRuntimeState({
1379
+ group,
1380
+ priorState: progress.groups[group.id],
1381
+ activeIds,
1382
+ seenIds,
1383
+ tagsByCard,
1384
+ hierarchyConfigs,
1385
+ userTagElo,
1386
+ userGlobalElo
1387
+ });
1388
+ nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
1389
+ const directCards = this.buildDirectTargetCards(
1390
+ runtime,
1391
+ courseId,
1392
+ emittedIds
1393
+ );
1394
+ const supportCards = this.buildSupportCards(
1395
+ runtime,
1396
+ courseId,
1397
+ emittedIds
1398
+ );
1399
+ emitted.push(...directCards, ...supportCards);
1400
+ }
1401
+ if (emitted.length === 0) {
1402
+ logger.debug("[Prescribed] No prescribed targets/support emitted this run");
1403
+ await this.putStrategyState(nextState).catch((e) => {
1404
+ logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
1405
+ });
1298
1406
  return [];
1299
1407
  }
1300
- const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1301
- cardId,
1302
- courseId,
1303
- score: 1,
1304
- provenance: [
1305
- {
1306
- strategy: "prescribed",
1307
- strategyName: this.strategyName || this.name,
1308
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1309
- action: "generated",
1310
- score: 1,
1311
- reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1408
+ const finalCards = pickTopByScore(emitted, limit);
1409
+ const surfacedByGroup = /* @__PURE__ */ new Map();
1410
+ for (const card of finalCards) {
1411
+ const prov = card.provenance[0];
1412
+ const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1413
+ const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1414
+ if (!groupId) continue;
1415
+ if (!surfacedByGroup.has(groupId)) {
1416
+ surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
1417
+ }
1418
+ surfacedByGroup.get(groupId)[mode].push(card.cardId);
1419
+ }
1420
+ for (const group of this.config.groups) {
1421
+ const groupState = nextState.groups[group.id];
1422
+ const surfaced = surfacedByGroup.get(group.id);
1423
+ if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
1424
+ groupState.lastSurfacedAt = isoNow();
1425
+ groupState.sessionsSinceSurfaced = 0;
1426
+ if (surfaced.supportIds.length > 0) {
1427
+ groupState.lastSupportAt = isoNow();
1312
1428
  }
1313
- ]
1314
- }));
1429
+ }
1430
+ }
1431
+ await this.putStrategyState(nextState).catch((e) => {
1432
+ logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
1433
+ });
1315
1434
  logger.info(
1316
- `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1435
+ `[Prescribed] Emitting ${finalCards.length} cards (${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=target")).length} target, ${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=support")).length} support)`
1317
1436
  );
1437
+ return finalCards;
1438
+ }
1439
+ parseConfig(serializedData) {
1440
+ try {
1441
+ const parsed = JSON.parse(serializedData);
1442
+ const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
1443
+ const groups = groupsRaw.map((raw, i) => ({
1444
+ id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
1445
+ targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
1446
+ supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
1447
+ supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
1448
+ freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
1449
+ maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
1450
+ maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
1451
+ hierarchyWalk: {
1452
+ enabled: raw.hierarchyWalk?.enabled !== false,
1453
+ maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1454
+ },
1455
+ retireOnEncounter: raw.retireOnEncounter !== false
1456
+ })).filter((g) => g.targetCardIds.length > 0);
1457
+ return { groups };
1458
+ } catch {
1459
+ return { groups: [] };
1460
+ }
1461
+ }
1462
+ async loadHierarchyConfigs() {
1463
+ try {
1464
+ const strategies = await this.course.getNavigationStrategies();
1465
+ return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
1466
+ try {
1467
+ const parsed = JSON.parse(s.serializedData);
1468
+ return {
1469
+ prerequisites: parsed.prerequisites || {}
1470
+ };
1471
+ } catch {
1472
+ return { prerequisites: {} };
1473
+ }
1474
+ });
1475
+ } catch (e) {
1476
+ logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
1477
+ return [];
1478
+ }
1479
+ }
1480
+ buildGroupRuntimeState(args) {
1481
+ const {
1482
+ group,
1483
+ priorState,
1484
+ activeIds,
1485
+ seenIds,
1486
+ tagsByCard,
1487
+ hierarchyConfigs,
1488
+ userTagElo,
1489
+ userGlobalElo
1490
+ } = args;
1491
+ const encounteredTargets = /* @__PURE__ */ new Set();
1492
+ for (const cardId of group.targetCardIds) {
1493
+ if (activeIds.has(cardId) || seenIds.has(cardId)) {
1494
+ encounteredTargets.add(cardId);
1495
+ }
1496
+ }
1497
+ if (priorState?.encounteredCardIds?.length) {
1498
+ for (const cardId of priorState.encounteredCardIds) {
1499
+ encounteredTargets.add(cardId);
1500
+ }
1501
+ }
1502
+ const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
1503
+ const targetTags = /* @__PURE__ */ new Map();
1504
+ for (const cardId of pendingTargets) {
1505
+ targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
1506
+ }
1507
+ const blockedTargets = [];
1508
+ const surfaceableTargets = [];
1509
+ const supportTags = /* @__PURE__ */ new Set();
1510
+ for (const cardId of pendingTargets) {
1511
+ const tags = targetTags.get(cardId) ?? [];
1512
+ const resolution = this.resolveBlockedSupportTags(
1513
+ tags,
1514
+ hierarchyConfigs,
1515
+ userTagElo,
1516
+ userGlobalElo,
1517
+ group.hierarchyWalk?.enabled !== false,
1518
+ group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
1519
+ );
1520
+ if (resolution.blocked) {
1521
+ blockedTargets.push(cardId);
1522
+ resolution.supportTags.forEach((t) => supportTags.add(t));
1523
+ } else {
1524
+ surfaceableTargets.push(cardId);
1525
+ }
1526
+ }
1527
+ const supportCandidates = dedupe([
1528
+ ...group.supportCardIds ?? [],
1529
+ ...this.findSupportCardsByTags(
1530
+ group,
1531
+ tagsByCard,
1532
+ [...supportTags]
1533
+ )
1534
+ ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
1535
+ const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
1536
+ const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
1537
+ const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
1538
+ const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
1539
+ const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
1540
+ return {
1541
+ group,
1542
+ encounteredTargets,
1543
+ pendingTargets,
1544
+ blockedTargets,
1545
+ surfaceableTargets,
1546
+ targetTags,
1547
+ supportCandidates,
1548
+ supportTags: [...supportTags],
1549
+ pressureMultiplier,
1550
+ supportMultiplier
1551
+ };
1552
+ }
1553
+ buildNextGroupState(runtime, prior) {
1554
+ const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
1555
+ const surfacedThisRun = false;
1556
+ return {
1557
+ encounteredCardIds: [...runtime.encounteredTargets].sort(),
1558
+ lastSurfacedAt: prior?.lastSurfacedAt ?? null,
1559
+ sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
1560
+ lastSupportAt: prior?.lastSupportAt ?? null,
1561
+ blockedTargetIds: [...runtime.blockedTargets].sort(),
1562
+ lastResolvedSupportTags: [...runtime.supportTags].sort()
1563
+ };
1564
+ }
1565
+ buildDirectTargetCards(runtime, courseId, emittedIds) {
1566
+ const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
1567
+ const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
1568
+ const cards = [];
1569
+ for (const cardId of directIds) {
1570
+ emittedIds.add(cardId);
1571
+ cards.push({
1572
+ cardId,
1573
+ courseId,
1574
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1575
+ provenance: [
1576
+ {
1577
+ strategy: "prescribed",
1578
+ strategyName: this.strategyName || this.name,
1579
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1580
+ action: "generated",
1581
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1582
+ reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
1583
+ }
1584
+ ]
1585
+ });
1586
+ }
1587
+ return cards;
1588
+ }
1589
+ buildSupportCards(runtime, courseId, emittedIds) {
1590
+ if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
1591
+ return [];
1592
+ }
1593
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
1594
+ const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
1595
+ const cards = [];
1596
+ for (const cardId of supportIds) {
1597
+ emittedIds.add(cardId);
1598
+ cards.push({
1599
+ cardId,
1600
+ courseId,
1601
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1602
+ provenance: [
1603
+ {
1604
+ strategy: "prescribed",
1605
+ strategyName: this.strategyName || this.name,
1606
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1607
+ action: "generated",
1608
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1609
+ reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
1610
+ }
1611
+ ]
1612
+ });
1613
+ }
1318
1614
  return cards;
1319
1615
  }
1616
+ findSupportCardsByTags(group, tagsByCard, supportTags) {
1617
+ if (supportTags.length === 0) {
1618
+ return [];
1619
+ }
1620
+ const explicitSupportIds = group.supportCardIds ?? [];
1621
+ const explicitPatterns = group.supportTagPatterns ?? [];
1622
+ if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
1623
+ return [];
1624
+ }
1625
+ const candidates = /* @__PURE__ */ new Set();
1626
+ for (const cardId of explicitSupportIds) {
1627
+ const cardTags = tagsByCard.get(cardId) ?? [];
1628
+ const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
1629
+ const matchesPattern = explicitPatterns.some(
1630
+ (pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
1631
+ );
1632
+ if (matchesResolved || matchesPattern) {
1633
+ candidates.add(cardId);
1634
+ }
1635
+ }
1636
+ return [...candidates];
1637
+ }
1638
+ resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
1639
+ if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
1640
+ return {
1641
+ blocked: false,
1642
+ supportTags: []
1643
+ };
1644
+ }
1645
+ const supportTags = /* @__PURE__ */ new Set();
1646
+ let blocked = false;
1647
+ for (const targetTag of targetTags) {
1648
+ for (const hierarchy of hierarchyConfigs) {
1649
+ const prereqs = hierarchy.prerequisites[targetTag];
1650
+ if (!prereqs || prereqs.length === 0) continue;
1651
+ const unmet = prereqs.filter(
1652
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1653
+ );
1654
+ if (unmet.length === 0) {
1655
+ continue;
1656
+ }
1657
+ blocked = true;
1658
+ for (const prereq of unmet) {
1659
+ this.collectSupportTagsRecursive(
1660
+ prereq.tag,
1661
+ hierarchyConfigs,
1662
+ userTagElo,
1663
+ userGlobalElo,
1664
+ maxDepth,
1665
+ /* @__PURE__ */ new Set(),
1666
+ supportTags
1667
+ );
1668
+ }
1669
+ }
1670
+ }
1671
+ return { blocked, supportTags: [...supportTags] };
1672
+ }
1673
+ collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
1674
+ if (depth < 0 || visited.has(tag)) return;
1675
+ if (this.isHardGatedTag(tag)) return;
1676
+ visited.add(tag);
1677
+ let walkedFurther = false;
1678
+ for (const hierarchy of hierarchyConfigs) {
1679
+ const prereqs = hierarchy.prerequisites[tag];
1680
+ if (!prereqs || prereqs.length === 0) continue;
1681
+ const unmet = prereqs.filter(
1682
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1683
+ );
1684
+ if (unmet.length > 0 && depth > 0) {
1685
+ walkedFurther = true;
1686
+ for (const prereq of unmet) {
1687
+ this.collectSupportTagsRecursive(
1688
+ prereq.tag,
1689
+ hierarchyConfigs,
1690
+ userTagElo,
1691
+ userGlobalElo,
1692
+ depth - 1,
1693
+ visited,
1694
+ out
1695
+ );
1696
+ }
1697
+ }
1698
+ }
1699
+ if (!walkedFurther) {
1700
+ out.add(tag);
1701
+ }
1702
+ }
1703
+ isHardGatedTag(tag) {
1704
+ return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
1705
+ }
1706
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1707
+ if (!userTagElo) return false;
1708
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1709
+ if (userTagElo.count < minCount) return false;
1710
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1711
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1712
+ }
1713
+ if (prereq.masteryThreshold?.minCount !== void 0) {
1714
+ return true;
1715
+ }
1716
+ return userTagElo.score >= userGlobalElo;
1717
+ }
1320
1718
  };
1321
1719
  }
1322
1720
  });
@@ -1680,13 +2078,13 @@ __export(hierarchyDefinition_exports, {
1680
2078
  default: () => HierarchyDefinitionNavigator
1681
2079
  });
1682
2080
  import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1683
- var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
2081
+ var DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
1684
2082
  var init_hierarchyDefinition = __esm({
1685
2083
  "src/core/navigators/filters/hierarchyDefinition.ts"() {
1686
2084
  "use strict";
1687
2085
  init_navigators();
1688
2086
  init_logger();
1689
- DEFAULT_MIN_COUNT = 3;
2087
+ DEFAULT_MIN_COUNT2 = 3;
1690
2088
  HierarchyDefinitionNavigator = class extends ContentNavigator {
1691
2089
  config;
1692
2090
  /** Human-readable name for CardFilter interface */
@@ -1713,7 +2111,7 @@ var init_hierarchyDefinition = __esm({
1713
2111
  */
1714
2112
  isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1715
2113
  if (!userTagElo) return false;
1716
- const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
2114
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1717
2115
  if (userTagElo.count < minCount) return false;
1718
2116
  if (prereq.masteryThreshold?.minElo !== void 0) {
1719
2117
  return userTagElo.score >= prereq.masteryThreshold.minElo;
@@ -1814,18 +2212,55 @@ var init_hierarchyDefinition = __esm({
1814
2212
  }
1815
2213
  return boosts;
1816
2214
  }
2215
+ /**
2216
+ * Build a map of gated tag → max configured targetBoost for all *open* gates.
2217
+ *
2218
+ * When a gate opens (prereqs met), cards carrying the gated tag get boosted —
2219
+ * ensuring newly-unlocked content surfaces promptly. The boost is a static
2220
+ * multiplier; natural ELO/SRS deprioritization after interaction handles decay.
2221
+ */
2222
+ getTargetBoosts(unlockedTags) {
2223
+ const boosts = /* @__PURE__ */ new Map();
2224
+ const configKeys = Object.keys(this.config.prerequisites);
2225
+ const unlockedArr = [...unlockedTags];
2226
+ logger.info(
2227
+ `[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
2228
+ );
2229
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2230
+ if (!unlockedTags.has(tagId)) continue;
2231
+ logger.info(
2232
+ `[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
2233
+ );
2234
+ for (const prereq of prereqs) {
2235
+ if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
2236
+ const existing = boosts.get(tagId) ?? 1;
2237
+ boosts.set(tagId, Math.max(existing, prereq.targetBoost));
2238
+ }
2239
+ }
2240
+ if (boosts.size > 0) {
2241
+ logger.info(
2242
+ `[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
2243
+ );
2244
+ } else {
2245
+ logger.info(
2246
+ `[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
2247
+ );
2248
+ }
2249
+ return boosts;
2250
+ }
1817
2251
  /**
1818
2252
  * CardFilter.transform implementation.
1819
2253
  *
1820
- * Two effects:
1821
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1822
- * 2. Cards carrying prereq tags of closed gates receive a configured
1823
- * boost (preReqBoost), steering toward content that unlocks gates
2254
+ * Three effects:
2255
+ * 1. Cards with locked tags receive score * 0.02 (gating penalty)
2256
+ * 2. Cards carrying prereq tags of closed gates receive preReqBoost
2257
+ * 3. Cards carrying gated tags of open gates receive targetBoost
1824
2258
  */
1825
2259
  async transform(cards, context) {
1826
2260
  const masteredTags = await this.getMasteredTags(context);
1827
2261
  const unlockedTags = this.getUnlockedTags(masteredTags);
1828
2262
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
2263
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
1829
2264
  const gated = [];
1830
2265
  for (const card of cards) {
1831
2266
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1858,6 +2293,26 @@ var init_hierarchyDefinition = __esm({
1858
2293
  );
1859
2294
  }
1860
2295
  }
2296
+ if (isUnlocked && targetBoosts.size > 0) {
2297
+ const cardTags = card.tags ?? [];
2298
+ let maxTargetBoost = 1;
2299
+ const boostedTargets = [];
2300
+ for (const tag of cardTags) {
2301
+ const boost = targetBoosts.get(tag);
2302
+ if (boost && boost > maxTargetBoost) {
2303
+ maxTargetBoost = boost;
2304
+ boostedTargets.push(tag);
2305
+ }
2306
+ }
2307
+ if (maxTargetBoost > 1) {
2308
+ finalScore *= maxTargetBoost;
2309
+ action = "boosted";
2310
+ finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
2311
+ logger.info(
2312
+ `[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2313
+ );
2314
+ }
2315
+ }
1861
2316
  gated.push({
1862
2317
  ...card,
1863
2318
  score: finalScore,
@@ -2043,12 +2498,12 @@ __export(interferenceMitigator_exports, {
2043
2498
  default: () => InterferenceMitigatorNavigator
2044
2499
  });
2045
2500
  import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
2046
- var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2501
+ var DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2047
2502
  var init_interferenceMitigator = __esm({
2048
2503
  "src/core/navigators/filters/interferenceMitigator.ts"() {
2049
2504
  "use strict";
2050
2505
  init_navigators();
2051
- DEFAULT_MIN_COUNT2 = 10;
2506
+ DEFAULT_MIN_COUNT3 = 10;
2052
2507
  DEFAULT_MIN_ELAPSED_DAYS = 3;
2053
2508
  DEFAULT_INTERFERENCE_DECAY = 0.8;
2054
2509
  InterferenceMitigatorNavigator = class extends ContentNavigator {
@@ -2073,7 +2528,7 @@ var init_interferenceMitigator = __esm({
2073
2528
  return {
2074
2529
  interferenceSets: sets,
2075
2530
  maturityThreshold: {
2076
- minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2531
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
2077
2532
  minElo: parsed.maturityThreshold?.minElo,
2078
2533
  minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2079
2534
  },
@@ -2083,7 +2538,7 @@ var init_interferenceMitigator = __esm({
2083
2538
  return {
2084
2539
  interferenceSets: [],
2085
2540
  maturityThreshold: {
2086
- minCount: DEFAULT_MIN_COUNT2,
2541
+ minCount: DEFAULT_MIN_COUNT3,
2087
2542
  minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2088
2543
  },
2089
2544
  defaultDecay: DEFAULT_INTERFERENCE_DECAY
@@ -2130,7 +2585,7 @@ var init_interferenceMitigator = __esm({
2130
2585
  try {
2131
2586
  const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2132
2587
  const userElo = toCourseElo4(courseReg.elo);
2133
- const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2588
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
2134
2589
  const minElo = this.config.maturityThreshold?.minElo;
2135
2590
  const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2136
2591
  const minCountForElapsed = minElapsedDays * 2;
@@ -2441,114 +2896,1704 @@ var init_2 = __esm({
2441
2896
  }
2442
2897
  });
2443
2898
 
2444
- // src/core/orchestration/gradient.ts
2445
- var init_gradient = __esm({
2446
- "src/core/orchestration/gradient.ts"() {
2447
- "use strict";
2448
- init_logger();
2449
- }
2450
- });
2899
+ // src/core/orchestration/gradient.ts
2900
+ var init_gradient = __esm({
2901
+ "src/core/orchestration/gradient.ts"() {
2902
+ "use strict";
2903
+ init_logger();
2904
+ }
2905
+ });
2906
+
2907
+ // src/core/orchestration/learning.ts
2908
+ var init_learning = __esm({
2909
+ "src/core/orchestration/learning.ts"() {
2910
+ "use strict";
2911
+ init_contentNavigationStrategy();
2912
+ init_types_legacy();
2913
+ init_logger();
2914
+ }
2915
+ });
2916
+
2917
+ // src/core/orchestration/signal.ts
2918
+ var init_signal = __esm({
2919
+ "src/core/orchestration/signal.ts"() {
2920
+ "use strict";
2921
+ }
2922
+ });
2923
+
2924
+ // src/core/orchestration/recording.ts
2925
+ var init_recording = __esm({
2926
+ "src/core/orchestration/recording.ts"() {
2927
+ "use strict";
2928
+ init_signal();
2929
+ init_types_legacy();
2930
+ init_logger();
2931
+ }
2932
+ });
2933
+
2934
+ // src/core/orchestration/index.ts
2935
+ function fnv1a(str) {
2936
+ let hash = 2166136261;
2937
+ for (let i = 0; i < str.length; i++) {
2938
+ hash ^= str.charCodeAt(i);
2939
+ hash = Math.imul(hash, 16777619);
2940
+ }
2941
+ return hash >>> 0;
2942
+ }
2943
+ function computeDeviation(userId, strategyId, salt) {
2944
+ const input = `${userId}:${strategyId}:${salt}`;
2945
+ const hash = fnv1a(input);
2946
+ const normalized = hash / 4294967296;
2947
+ return normalized * 2 - 1;
2948
+ }
2949
+ function computeSpread(confidence) {
2950
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
2951
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2952
+ }
2953
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2954
+ const deviation = computeDeviation(userId, strategyId, salt);
2955
+ const spread = computeSpread(learnable.confidence);
2956
+ const adjustment = deviation * spread * learnable.weight;
2957
+ const effective = learnable.weight + adjustment;
2958
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2959
+ }
2960
+ async function createOrchestrationContext(user, course) {
2961
+ let courseConfig;
2962
+ try {
2963
+ courseConfig = await course.getCourseConfig();
2964
+ } catch (e) {
2965
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
2966
+ courseConfig = {
2967
+ name: "Unknown",
2968
+ description: "",
2969
+ public: false,
2970
+ deleted: false,
2971
+ creator: "",
2972
+ admins: [],
2973
+ moderators: [],
2974
+ dataShapes: [],
2975
+ questionTypes: [],
2976
+ orchestration: { salt: "default" }
2977
+ };
2978
+ }
2979
+ const userId = user.getUsername();
2980
+ const salt = courseConfig.orchestration?.salt || "default_salt";
2981
+ return {
2982
+ user,
2983
+ course,
2984
+ userId,
2985
+ courseConfig,
2986
+ getEffectiveWeight(strategyId, learnable) {
2987
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
2988
+ },
2989
+ getDeviation(strategyId) {
2990
+ return computeDeviation(userId, strategyId, salt);
2991
+ }
2992
+ };
2993
+ }
2994
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2995
+ var init_orchestration = __esm({
2996
+ "src/core/orchestration/index.ts"() {
2997
+ "use strict";
2998
+ init_logger();
2999
+ init_gradient();
3000
+ init_learning();
3001
+ init_signal();
3002
+ init_recording();
3003
+ MIN_SPREAD = 0.1;
3004
+ MAX_SPREAD = 0.5;
3005
+ MIN_WEIGHT = 0.1;
3006
+ MAX_WEIGHT = 3;
3007
+ }
3008
+ });
3009
+
3010
+ // src/core/util/index.ts
3011
+ function getCardHistoryID(courseID, cardID) {
3012
+ return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
3013
+ }
3014
+ var init_util = __esm({
3015
+ "src/core/util/index.ts"() {
3016
+ "use strict";
3017
+ init_types_legacy();
3018
+ }
3019
+ });
3020
+
3021
+ // src/study/SpacedRepetition.ts
3022
+ import moment2 from "moment";
3023
+ import { isTaggedPerformance } from "@vue-skuilder/common";
3024
+ var duration;
3025
+ var init_SpacedRepetition = __esm({
3026
+ "src/study/SpacedRepetition.ts"() {
3027
+ "use strict";
3028
+ init_util();
3029
+ init_logger();
3030
+ duration = moment2.duration;
3031
+ }
3032
+ });
3033
+
3034
+ // src/study/services/SrsService.ts
3035
+ import moment3 from "moment";
3036
+ var init_SrsService = __esm({
3037
+ "src/study/services/SrsService.ts"() {
3038
+ "use strict";
3039
+ init_couch();
3040
+ init_SpacedRepetition();
3041
+ init_logger();
3042
+ }
3043
+ });
3044
+
3045
+ // src/study/services/EloService.ts
3046
+ import {
3047
+ adjustCourseScores,
3048
+ adjustCourseScoresPerTag,
3049
+ toCourseElo as toCourseElo5
3050
+ } from "@vue-skuilder/common";
3051
+ var init_EloService = __esm({
3052
+ "src/study/services/EloService.ts"() {
3053
+ "use strict";
3054
+ init_logger();
3055
+ }
3056
+ });
3057
+
3058
+ // src/study/services/ResponseProcessor.ts
3059
+ import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
3060
+ var init_ResponseProcessor = __esm({
3061
+ "src/study/services/ResponseProcessor.ts"() {
3062
+ "use strict";
3063
+ init_core();
3064
+ init_logger();
3065
+ }
3066
+ });
3067
+
3068
+ // src/study/services/CardHydrationService.ts
3069
+ import {
3070
+ displayableDataToViewData,
3071
+ isCourseElo,
3072
+ toCourseElo as toCourseElo6
3073
+ } from "@vue-skuilder/common";
3074
+ var init_CardHydrationService = __esm({
3075
+ "src/study/services/CardHydrationService.ts"() {
3076
+ "use strict";
3077
+ init_logger();
3078
+ }
3079
+ });
3080
+
3081
+ // src/study/ItemQueue.ts
3082
+ var init_ItemQueue = __esm({
3083
+ "src/study/ItemQueue.ts"() {
3084
+ "use strict";
3085
+ }
3086
+ });
3087
+
3088
+ // src/util/packer/types.ts
3089
+ var init_types3 = __esm({
3090
+ "src/util/packer/types.ts"() {
3091
+ "use strict";
3092
+ }
3093
+ });
3094
+
3095
+ // src/util/packer/CouchDBToStaticPacker.ts
3096
+ var init_CouchDBToStaticPacker = __esm({
3097
+ "src/util/packer/CouchDBToStaticPacker.ts"() {
3098
+ "use strict";
3099
+ init_types_legacy();
3100
+ init_logger();
3101
+ }
3102
+ });
3103
+
3104
+ // src/util/packer/index.ts
3105
+ var init_packer = __esm({
3106
+ "src/util/packer/index.ts"() {
3107
+ "use strict";
3108
+ init_types3();
3109
+ init_CouchDBToStaticPacker();
3110
+ }
3111
+ });
3112
+
3113
+ // src/util/migrator/types.ts
3114
+ var DEFAULT_MIGRATION_OPTIONS;
3115
+ var init_types4 = __esm({
3116
+ "src/util/migrator/types.ts"() {
3117
+ "use strict";
3118
+ DEFAULT_MIGRATION_OPTIONS = {
3119
+ chunkBatchSize: 100,
3120
+ validateRoundTrip: false,
3121
+ cleanupOnFailure: true,
3122
+ timeout: 3e5
3123
+ // 5 minutes
3124
+ };
3125
+ }
3126
+ });
3127
+
3128
+ // src/util/migrator/FileSystemAdapter.ts
3129
+ var FileSystemError;
3130
+ var init_FileSystemAdapter = __esm({
3131
+ "src/util/migrator/FileSystemAdapter.ts"() {
3132
+ "use strict";
3133
+ FileSystemError = class extends Error {
3134
+ constructor(message, operation, filePath, cause) {
3135
+ super(message);
3136
+ this.operation = operation;
3137
+ this.filePath = filePath;
3138
+ this.cause = cause;
3139
+ this.name = "FileSystemError";
3140
+ }
3141
+ };
3142
+ }
3143
+ });
3144
+
3145
+ // src/util/migrator/validation.ts
3146
+ async function validateStaticCourse(staticPath, fs) {
3147
+ const validation = {
3148
+ valid: true,
3149
+ manifestExists: false,
3150
+ chunksExist: false,
3151
+ attachmentsExist: false,
3152
+ errors: [],
3153
+ warnings: []
3154
+ };
3155
+ try {
3156
+ if (fs) {
3157
+ const stats = await fs.stat(staticPath);
3158
+ if (!stats.isDirectory()) {
3159
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3160
+ validation.valid = false;
3161
+ return validation;
3162
+ }
3163
+ } else if (!nodeFS) {
3164
+ validation.errors.push("File system access not available - validation skipped");
3165
+ validation.valid = false;
3166
+ return validation;
3167
+ } else {
3168
+ const stats = await nodeFS.promises.stat(staticPath);
3169
+ if (!stats.isDirectory()) {
3170
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3171
+ validation.valid = false;
3172
+ return validation;
3173
+ }
3174
+ }
3175
+ let manifestPath = `${staticPath}/manifest.json`;
3176
+ try {
3177
+ if (fs) {
3178
+ manifestPath = fs.joinPath(staticPath, "manifest.json");
3179
+ if (await fs.exists(manifestPath)) {
3180
+ validation.manifestExists = true;
3181
+ const manifestContent = await fs.readFile(manifestPath);
3182
+ const manifest = JSON.parse(manifestContent);
3183
+ validation.courseId = manifest.courseId;
3184
+ validation.courseName = manifest.courseName;
3185
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3186
+ validation.errors.push("Invalid manifest structure");
3187
+ validation.valid = false;
3188
+ }
3189
+ } else {
3190
+ validation.errors.push(`Manifest not found: ${manifestPath}`);
3191
+ validation.valid = false;
3192
+ }
3193
+ } else {
3194
+ manifestPath = `${staticPath}/manifest.json`;
3195
+ await nodeFS.promises.access(manifestPath);
3196
+ validation.manifestExists = true;
3197
+ const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
3198
+ const manifest = JSON.parse(manifestContent);
3199
+ validation.courseId = manifest.courseId;
3200
+ validation.courseName = manifest.courseName;
3201
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3202
+ validation.errors.push("Invalid manifest structure");
3203
+ validation.valid = false;
3204
+ }
3205
+ }
3206
+ } catch (error) {
3207
+ const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
3208
+ validation.errors.push(errorMessage);
3209
+ validation.valid = false;
3210
+ }
3211
+ let chunksPath = `${staticPath}/chunks`;
3212
+ try {
3213
+ if (fs) {
3214
+ chunksPath = fs.joinPath(staticPath, "chunks");
3215
+ if (await fs.exists(chunksPath)) {
3216
+ const chunksStats = await fs.stat(chunksPath);
3217
+ if (chunksStats.isDirectory()) {
3218
+ validation.chunksExist = true;
3219
+ } else {
3220
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3221
+ validation.valid = false;
3222
+ }
3223
+ } else {
3224
+ validation.errors.push(`Chunks directory not found: ${chunksPath}`);
3225
+ validation.valid = false;
3226
+ }
3227
+ } else {
3228
+ chunksPath = `${staticPath}/chunks`;
3229
+ const chunksStats = await nodeFS.promises.stat(chunksPath);
3230
+ if (chunksStats.isDirectory()) {
3231
+ validation.chunksExist = true;
3232
+ } else {
3233
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3234
+ validation.valid = false;
3235
+ }
3236
+ }
3237
+ } catch (error) {
3238
+ const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
3239
+ validation.errors.push(errorMessage);
3240
+ validation.valid = false;
3241
+ }
3242
+ let attachmentsPath;
3243
+ try {
3244
+ if (fs) {
3245
+ attachmentsPath = fs.joinPath(staticPath, "attachments");
3246
+ if (await fs.exists(attachmentsPath)) {
3247
+ const attachmentsStats = await fs.stat(attachmentsPath);
3248
+ if (attachmentsStats.isDirectory()) {
3249
+ validation.attachmentsExist = true;
3250
+ }
3251
+ } else {
3252
+ validation.warnings.push(
3253
+ `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
3254
+ );
3255
+ }
3256
+ } else {
3257
+ attachmentsPath = `${staticPath}/attachments`;
3258
+ const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
3259
+ if (attachmentsStats.isDirectory()) {
3260
+ validation.attachmentsExist = true;
3261
+ }
3262
+ }
3263
+ } catch (error) {
3264
+ attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
3265
+ const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
3266
+ validation.warnings.push(warningMessage);
3267
+ }
3268
+ } catch (error) {
3269
+ validation.errors.push(
3270
+ `Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
3271
+ );
3272
+ validation.valid = false;
3273
+ }
3274
+ return validation;
3275
+ }
3276
+ async function validateMigration(targetDB, expectedCounts, manifest) {
3277
+ const validation = {
3278
+ valid: true,
3279
+ documentCountMatch: false,
3280
+ attachmentIntegrity: false,
3281
+ viewFunctionality: false,
3282
+ issues: []
3283
+ };
3284
+ try {
3285
+ logger.info("Starting migration validation...");
3286
+ const actualCounts = await getActualDocumentCounts(targetDB);
3287
+ validation.documentCountMatch = compareDocumentCounts(
3288
+ expectedCounts,
3289
+ actualCounts,
3290
+ validation.issues
3291
+ );
3292
+ await validateCourseConfig(targetDB, manifest, validation.issues);
3293
+ validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
3294
+ validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
3295
+ validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
3296
+ logger.info(`Migration validation completed. Valid: ${validation.valid}`);
3297
+ if (validation.issues.length > 0) {
3298
+ logger.info(`Validation issues: ${validation.issues.length}`);
3299
+ validation.issues.forEach((issue) => {
3300
+ if (issue.type === "error") {
3301
+ logger.error(`${issue.category}: ${issue.message}`);
3302
+ } else {
3303
+ logger.warn(`${issue.category}: ${issue.message}`);
3304
+ }
3305
+ });
3306
+ }
3307
+ } catch (error) {
3308
+ validation.valid = false;
3309
+ validation.issues.push({
3310
+ type: "error",
3311
+ category: "metadata",
3312
+ message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
3313
+ });
3314
+ }
3315
+ return validation;
3316
+ }
3317
+ async function getActualDocumentCounts(db) {
3318
+ const counts = {};
3319
+ try {
3320
+ const allDocs = await db.allDocs({ include_docs: true });
3321
+ for (const row of allDocs.rows) {
3322
+ if (row.id.startsWith("_design/")) {
3323
+ counts["_design"] = (counts["_design"] || 0) + 1;
3324
+ continue;
3325
+ }
3326
+ const doc = row.doc;
3327
+ if (doc && doc.docType) {
3328
+ counts[doc.docType] = (counts[doc.docType] || 0) + 1;
3329
+ } else {
3330
+ counts["unknown"] = (counts["unknown"] || 0) + 1;
3331
+ }
3332
+ }
3333
+ } catch (error) {
3334
+ logger.error("Failed to get actual document counts:", error);
3335
+ }
3336
+ return counts;
3337
+ }
3338
+ function compareDocumentCounts(expected, actual, issues) {
3339
+ let countsMatch = true;
3340
+ for (const [docType, expectedCount] of Object.entries(expected)) {
3341
+ const actualCount = actual[docType] || 0;
3342
+ if (actualCount !== expectedCount) {
3343
+ countsMatch = false;
3344
+ issues.push({
3345
+ type: "error",
3346
+ category: "documents",
3347
+ message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
3348
+ });
3349
+ }
3350
+ }
3351
+ for (const [docType, actualCount] of Object.entries(actual)) {
3352
+ if (!expected[docType] && docType !== "_design") {
3353
+ issues.push({
3354
+ type: "warning",
3355
+ category: "documents",
3356
+ message: `Unexpected document type found: ${docType} (${actualCount} documents)`
3357
+ });
3358
+ }
3359
+ }
3360
+ return countsMatch;
3361
+ }
3362
+ async function validateCourseConfig(db, manifest, issues) {
3363
+ try {
3364
+ const courseConfig = await db.get("CourseConfig");
3365
+ if (!courseConfig) {
3366
+ issues.push({
3367
+ type: "error",
3368
+ category: "course_config",
3369
+ message: "CourseConfig document not found after migration"
3370
+ });
3371
+ return;
3372
+ }
3373
+ if (!courseConfig.courseID) {
3374
+ issues.push({
3375
+ type: "warning",
3376
+ category: "course_config",
3377
+ message: "CourseConfig document missing courseID field"
3378
+ });
3379
+ }
3380
+ if (courseConfig.courseID !== manifest.courseId) {
3381
+ issues.push({
3382
+ type: "warning",
3383
+ category: "course_config",
3384
+ message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
3385
+ });
3386
+ }
3387
+ logger.debug("CourseConfig document validation passed");
3388
+ } catch (error) {
3389
+ if (error.status === 404) {
3390
+ issues.push({
3391
+ type: "error",
3392
+ category: "course_config",
3393
+ message: "CourseConfig document not found in database"
3394
+ });
3395
+ } else {
3396
+ issues.push({
3397
+ type: "error",
3398
+ category: "course_config",
3399
+ message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
3400
+ });
3401
+ }
3402
+ }
3403
+ }
3404
+ async function validateViews(db, manifest, issues) {
3405
+ let viewsValid = true;
3406
+ try {
3407
+ for (const designDoc of manifest.designDocs) {
3408
+ try {
3409
+ const doc = await db.get(designDoc._id);
3410
+ if (!doc) {
3411
+ viewsValid = false;
3412
+ issues.push({
3413
+ type: "error",
3414
+ category: "views",
3415
+ message: `Design document not found: ${designDoc._id}`
3416
+ });
3417
+ continue;
3418
+ }
3419
+ for (const viewName of Object.keys(designDoc.views)) {
3420
+ try {
3421
+ const viewPath = `${designDoc._id}/${viewName}`;
3422
+ await db.query(viewPath, { limit: 1 });
3423
+ } catch (viewError) {
3424
+ viewsValid = false;
3425
+ issues.push({
3426
+ type: "error",
3427
+ category: "views",
3428
+ message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
3429
+ });
3430
+ }
3431
+ }
3432
+ } catch (error) {
3433
+ viewsValid = false;
3434
+ issues.push({
3435
+ type: "error",
3436
+ category: "views",
3437
+ message: `Failed to validate design document ${designDoc._id}: ${error}`
3438
+ });
3439
+ }
3440
+ }
3441
+ } catch (error) {
3442
+ viewsValid = false;
3443
+ issues.push({
3444
+ type: "error",
3445
+ category: "views",
3446
+ message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
3447
+ });
3448
+ }
3449
+ return viewsValid;
3450
+ }
3451
+ async function validateAttachmentIntegrity(db, issues) {
3452
+ let attachmentsValid = true;
3453
+ try {
3454
+ const allDocs = await db.allDocs({
3455
+ include_docs: true,
3456
+ limit: 10
3457
+ // Sample first 10 documents for performance
3458
+ });
3459
+ let attachmentCount = 0;
3460
+ let validAttachments = 0;
3461
+ for (const row of allDocs.rows) {
3462
+ const doc = row.doc;
3463
+ if (doc && doc._attachments) {
3464
+ for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
3465
+ attachmentCount++;
3466
+ try {
3467
+ const attachment = await db.getAttachment(doc._id, attachmentName);
3468
+ if (attachment) {
3469
+ validAttachments++;
3470
+ }
3471
+ } catch (attachmentError) {
3472
+ attachmentsValid = false;
3473
+ issues.push({
3474
+ type: "error",
3475
+ category: "attachments",
3476
+ message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
3477
+ });
3478
+ }
3479
+ }
3480
+ }
3481
+ }
3482
+ if (attachmentCount === 0) {
3483
+ issues.push({
3484
+ type: "warning",
3485
+ category: "attachments",
3486
+ message: "No attachments found in sampled documents"
3487
+ });
3488
+ } else {
3489
+ logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
3490
+ }
3491
+ } catch (error) {
3492
+ attachmentsValid = false;
3493
+ issues.push({
3494
+ type: "error",
3495
+ category: "attachments",
3496
+ message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
3497
+ });
3498
+ }
3499
+ return attachmentsValid;
3500
+ }
3501
+ var nodeFS;
3502
+ var init_validation = __esm({
3503
+ "src/util/migrator/validation.ts"() {
3504
+ "use strict";
3505
+ init_logger();
3506
+ init_FileSystemAdapter();
3507
+ nodeFS = null;
3508
+ try {
3509
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3510
+ nodeFS = eval("require")("fs");
3511
+ nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
3512
+ }
3513
+ } catch {
3514
+ }
3515
+ }
3516
+ });
3517
+
3518
+ // src/util/migrator/StaticToCouchDBMigrator.ts
3519
+ var nodeFS2, nodePath, StaticToCouchDBMigrator;
3520
+ var init_StaticToCouchDBMigrator = __esm({
3521
+ "src/util/migrator/StaticToCouchDBMigrator.ts"() {
3522
+ "use strict";
3523
+ init_logger();
3524
+ init_types4();
3525
+ init_validation();
3526
+ init_FileSystemAdapter();
3527
+ nodeFS2 = null;
3528
+ nodePath = null;
3529
+ try {
3530
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3531
+ nodeFS2 = eval("require")("fs");
3532
+ nodePath = eval("require")("path");
3533
+ nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
3534
+ }
3535
+ } catch {
3536
+ }
3537
+ StaticToCouchDBMigrator = class {
3538
+ options;
3539
+ progressCallback;
3540
+ fs;
3541
+ constructor(options = {}, fileSystemAdapter) {
3542
+ this.options = {
3543
+ ...DEFAULT_MIGRATION_OPTIONS,
3544
+ ...options
3545
+ };
3546
+ this.fs = fileSystemAdapter;
3547
+ }
3548
+ /**
3549
+ * Set a progress callback to receive updates during migration
3550
+ */
3551
+ setProgressCallback(callback) {
3552
+ this.progressCallback = callback;
3553
+ }
3554
+ /**
3555
+ * Migrate a static course to CouchDB
3556
+ */
3557
+ async migrateCourse(staticPath, targetDB) {
3558
+ const startTime = Date.now();
3559
+ const result = {
3560
+ success: false,
3561
+ documentsRestored: 0,
3562
+ attachmentsRestored: 0,
3563
+ designDocsRestored: 0,
3564
+ courseConfigRestored: 0,
3565
+ errors: [],
3566
+ warnings: [],
3567
+ migrationTime: 0
3568
+ };
3569
+ try {
3570
+ logger.info(`Starting migration from ${staticPath} to CouchDB`);
3571
+ this.reportProgress("manifest", 0, 1, "Validating static course...");
3572
+ const validation = await validateStaticCourse(staticPath, this.fs);
3573
+ if (!validation.valid) {
3574
+ result.errors.push(...validation.errors);
3575
+ throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
3576
+ }
3577
+ result.warnings.push(...validation.warnings);
3578
+ this.reportProgress("manifest", 1, 1, "Loading course manifest...");
3579
+ const manifest = await this.loadManifest(staticPath);
3580
+ logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
3581
+ this.reportProgress(
3582
+ "design_docs",
3583
+ 0,
3584
+ manifest.designDocs.length,
3585
+ "Restoring design documents..."
3586
+ );
3587
+ const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
3588
+ result.designDocsRestored = designDocResults.restored;
3589
+ result.errors.push(...designDocResults.errors);
3590
+ result.warnings.push(...designDocResults.warnings);
3591
+ this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
3592
+ const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
3593
+ result.courseConfigRestored = courseConfigResults.restored;
3594
+ result.errors.push(...courseConfigResults.errors);
3595
+ result.warnings.push(...courseConfigResults.warnings);
3596
+ this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
3597
+ const expectedCounts = this.calculateExpectedCounts(manifest);
3598
+ this.reportProgress(
3599
+ "documents",
3600
+ 0,
3601
+ manifest.documentCount,
3602
+ "Aggregating documents from chunks..."
3603
+ );
3604
+ const documents = await this.aggregateDocuments(staticPath, manifest);
3605
+ const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
3606
+ if (documents.length !== filteredDocuments.length) {
3607
+ result.warnings.push(
3608
+ `Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
3609
+ );
3610
+ }
3611
+ this.reportProgress(
3612
+ "documents",
3613
+ filteredDocuments.length,
3614
+ manifest.documentCount,
3615
+ "Uploading documents to CouchDB..."
3616
+ );
3617
+ const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
3618
+ result.documentsRestored = docResults.restored;
3619
+ result.errors.push(...docResults.errors);
3620
+ result.warnings.push(...docResults.warnings);
3621
+ const docsWithAttachments = documents.filter(
3622
+ (doc) => doc._attachments && Object.keys(doc._attachments).length > 0
3623
+ );
3624
+ this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
3625
+ const attachmentResults = await this.uploadAttachments(
3626
+ staticPath,
3627
+ docsWithAttachments,
3628
+ targetDB
3629
+ );
3630
+ result.attachmentsRestored = attachmentResults.restored;
3631
+ result.errors.push(...attachmentResults.errors);
3632
+ result.warnings.push(...attachmentResults.warnings);
3633
+ if (this.options.validateRoundTrip) {
3634
+ this.reportProgress("validation", 0, 1, "Validating migration...");
3635
+ const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
3636
+ if (!validationResult.valid) {
3637
+ result.warnings.push("Migration validation found issues");
3638
+ validationResult.issues.forEach((issue) => {
3639
+ if (issue.type === "error") {
3640
+ result.errors.push(`Validation: ${issue.message}`);
3641
+ } else {
3642
+ result.warnings.push(`Validation: ${issue.message}`);
3643
+ }
3644
+ });
3645
+ }
3646
+ this.reportProgress("validation", 1, 1, "Migration validation completed");
3647
+ }
3648
+ result.success = result.errors.length === 0;
3649
+ result.migrationTime = Date.now() - startTime;
3650
+ logger.info(`Migration completed in ${result.migrationTime}ms`);
3651
+ logger.info(`Documents restored: ${result.documentsRestored}`);
3652
+ logger.info(`Attachments restored: ${result.attachmentsRestored}`);
3653
+ logger.info(`Design docs restored: ${result.designDocsRestored}`);
3654
+ logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
3655
+ if (result.errors.length > 0) {
3656
+ logger.error(`Migration completed with ${result.errors.length} errors`);
3657
+ }
3658
+ if (result.warnings.length > 0) {
3659
+ logger.warn(`Migration completed with ${result.warnings.length} warnings`);
3660
+ }
3661
+ } catch (error) {
3662
+ result.success = false;
3663
+ result.migrationTime = Date.now() - startTime;
3664
+ const errorMessage = error instanceof Error ? error.message : String(error);
3665
+ result.errors.push(`Migration failed: ${errorMessage}`);
3666
+ logger.error("Migration failed:", error);
3667
+ if (this.options.cleanupOnFailure) {
3668
+ try {
3669
+ await this.cleanupFailedMigration(targetDB);
3670
+ } catch (cleanupError) {
3671
+ logger.error("Failed to cleanup after migration failure:", cleanupError);
3672
+ result.warnings.push("Failed to cleanup after migration failure");
3673
+ }
3674
+ }
3675
+ }
3676
+ return result;
3677
+ }
3678
+ /**
3679
+ * Load and parse the manifest file
3680
+ */
3681
+ async loadManifest(staticPath) {
3682
+ try {
3683
+ let manifestContent;
3684
+ let manifestPath;
3685
+ if (this.fs) {
3686
+ manifestPath = this.fs.joinPath(staticPath, "manifest.json");
3687
+ manifestContent = await this.fs.readFile(manifestPath);
3688
+ } else {
3689
+ manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
3690
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3691
+ manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
3692
+ } else {
3693
+ const response = await fetch(manifestPath);
3694
+ if (!response.ok) {
3695
+ throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
3696
+ }
3697
+ manifestContent = await response.text();
3698
+ }
3699
+ }
3700
+ const manifest = JSON.parse(manifestContent);
3701
+ if (!manifest.version || !manifest.courseId || !manifest.chunks) {
3702
+ throw new Error("Invalid manifest structure");
3703
+ }
3704
+ return manifest;
3705
+ } catch (error) {
3706
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
3707
+ throw new Error(errorMessage);
3708
+ }
3709
+ }
3710
+ /**
3711
+ * Restore design documents to CouchDB
3712
+ */
3713
+ async restoreDesignDocuments(designDocs, db) {
3714
+ const result = { restored: 0, errors: [], warnings: [] };
3715
+ for (let i = 0; i < designDocs.length; i++) {
3716
+ const designDoc = designDocs[i];
3717
+ this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
3718
+ try {
3719
+ let existingDoc;
3720
+ try {
3721
+ existingDoc = await db.get(designDoc._id);
3722
+ } catch {
3723
+ }
3724
+ const docToInsert = {
3725
+ _id: designDoc._id,
3726
+ views: designDoc.views
3727
+ };
3728
+ if (existingDoc) {
3729
+ docToInsert._rev = existingDoc._rev;
3730
+ logger.debug(`Updating existing design document: ${designDoc._id}`);
3731
+ } else {
3732
+ logger.debug(`Creating new design document: ${designDoc._id}`);
3733
+ }
3734
+ await db.put(docToInsert);
3735
+ result.restored++;
3736
+ } catch (error) {
3737
+ const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
3738
+ result.errors.push(errorMessage);
3739
+ logger.error(errorMessage);
3740
+ }
3741
+ }
3742
+ this.reportProgress(
3743
+ "design_docs",
3744
+ designDocs.length,
3745
+ designDocs.length,
3746
+ `Restored ${result.restored} design documents`
3747
+ );
3748
+ return result;
3749
+ }
3750
+ /**
3751
+ * Aggregate documents from all chunks
3752
+ */
3753
+ async aggregateDocuments(staticPath, manifest) {
3754
+ const allDocuments = [];
3755
+ const documentMap = /* @__PURE__ */ new Map();
3756
+ for (let i = 0; i < manifest.chunks.length; i++) {
3757
+ const chunk = manifest.chunks[i];
3758
+ this.reportProgress(
3759
+ "documents",
3760
+ allDocuments.length,
3761
+ manifest.documentCount,
3762
+ `Loading chunk ${chunk.id}...`
3763
+ );
3764
+ try {
3765
+ const documents = await this.loadChunk(staticPath, chunk);
3766
+ for (const doc of documents) {
3767
+ if (!doc._id) {
3768
+ logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
3769
+ continue;
3770
+ }
3771
+ if (documentMap.has(doc._id)) {
3772
+ logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
3773
+ }
3774
+ documentMap.set(doc._id, doc);
3775
+ }
3776
+ } catch (error) {
3777
+ throw new Error(
3778
+ `Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
3779
+ );
3780
+ }
3781
+ }
3782
+ allDocuments.push(...documentMap.values());
3783
+ logger.info(
3784
+ `Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
3785
+ );
3786
+ return allDocuments;
3787
+ }
3788
+ /**
3789
+ * Load documents from a single chunk file
3790
+ */
3791
+ async loadChunk(staticPath, chunk) {
3792
+ try {
3793
+ let chunkContent;
3794
+ let chunkPath;
3795
+ if (this.fs) {
3796
+ chunkPath = this.fs.joinPath(staticPath, chunk.path);
3797
+ chunkContent = await this.fs.readFile(chunkPath);
3798
+ } else {
3799
+ chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
3800
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3801
+ chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
3802
+ } else {
3803
+ const response = await fetch(chunkPath);
3804
+ if (!response.ok) {
3805
+ throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
3806
+ }
3807
+ chunkContent = await response.text();
3808
+ }
3809
+ }
3810
+ const documents = JSON.parse(chunkContent);
3811
+ if (!Array.isArray(documents)) {
3812
+ throw new Error("Chunk file does not contain an array of documents");
3813
+ }
3814
+ return documents;
3815
+ } catch (error) {
3816
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
3817
+ throw new Error(errorMessage);
3818
+ }
3819
+ }
3820
+ /**
3821
+ * Upload documents to CouchDB in batches
3822
+ */
3823
+ async uploadDocuments(documents, db) {
3824
+ const result = { restored: 0, errors: [], warnings: [] };
3825
+ const batchSize = this.options.chunkBatchSize;
3826
+ for (let i = 0; i < documents.length; i += batchSize) {
3827
+ const batch = documents.slice(i, i + batchSize);
3828
+ this.reportProgress(
3829
+ "documents",
3830
+ i,
3831
+ documents.length,
3832
+ `Uploading batch ${Math.floor(i / batchSize) + 1}...`
3833
+ );
3834
+ try {
3835
+ const docsToInsert = batch.map((doc) => {
3836
+ const cleanDoc = { ...doc };
3837
+ delete cleanDoc._rev;
3838
+ delete cleanDoc._attachments;
3839
+ return cleanDoc;
3840
+ });
3841
+ const bulkResult = await db.bulkDocs(docsToInsert);
3842
+ for (let j = 0; j < bulkResult.length; j++) {
3843
+ const docResult = bulkResult[j];
3844
+ const originalDoc = batch[j];
3845
+ if ("error" in docResult) {
3846
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
3847
+ result.errors.push(errorMessage);
3848
+ logger.error(errorMessage);
3849
+ } else {
3850
+ result.restored++;
3851
+ }
3852
+ }
3853
+ } catch (error) {
3854
+ let errorMessage;
3855
+ if (error instanceof Error) {
3856
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3857
+ } else if (error && typeof error === "object" && "message" in error) {
3858
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3859
+ } else {
3860
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
3861
+ }
3862
+ result.errors.push(errorMessage);
3863
+ logger.error(errorMessage);
3864
+ }
3865
+ }
3866
+ this.reportProgress(
3867
+ "documents",
3868
+ documents.length,
3869
+ documents.length,
3870
+ `Uploaded ${result.restored} documents`
3871
+ );
3872
+ return result;
3873
+ }
3874
+ /**
3875
+ * Upload attachments from filesystem to CouchDB
3876
+ */
3877
+ async uploadAttachments(staticPath, documents, db) {
3878
+ const result = { restored: 0, errors: [], warnings: [] };
3879
+ let processedDocs = 0;
3880
+ for (const doc of documents) {
3881
+ this.reportProgress(
3882
+ "attachments",
3883
+ processedDocs,
3884
+ documents.length,
3885
+ `Processing attachments for ${doc._id}...`
3886
+ );
3887
+ processedDocs++;
3888
+ if (!doc._attachments) {
3889
+ continue;
3890
+ }
3891
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
3892
+ try {
3893
+ const uploadResult = await this.uploadSingleAttachment(
3894
+ staticPath,
3895
+ doc._id,
3896
+ attachmentName,
3897
+ attachmentMeta,
3898
+ db
3899
+ );
3900
+ if (uploadResult.success) {
3901
+ result.restored++;
3902
+ } else {
3903
+ result.errors.push(uploadResult.error || "Unknown attachment upload error");
3904
+ }
3905
+ } catch (error) {
3906
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
3907
+ result.errors.push(errorMessage);
3908
+ logger.error(errorMessage);
3909
+ }
3910
+ }
3911
+ }
3912
+ this.reportProgress(
3913
+ "attachments",
3914
+ documents.length,
3915
+ documents.length,
3916
+ `Uploaded ${result.restored} attachments`
3917
+ );
3918
+ return result;
3919
+ }
3920
+ /**
3921
+ * Upload a single attachment file
3922
+ */
3923
+ async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
3924
+ const result = {
3925
+ success: false,
3926
+ attachmentName,
3927
+ docId
3928
+ };
3929
+ try {
3930
+ if (!attachmentMeta.path) {
3931
+ result.error = "Attachment metadata missing file path";
3932
+ return result;
3933
+ }
3934
+ let attachmentData;
3935
+ let attachmentPath;
3936
+ if (this.fs) {
3937
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
3938
+ attachmentData = await this.fs.readBinary(attachmentPath);
3939
+ } else {
3940
+ attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
3941
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3942
+ attachmentData = await nodeFS2.promises.readFile(attachmentPath);
3943
+ } else {
3944
+ const response = await fetch(attachmentPath);
3945
+ if (!response.ok) {
3946
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
3947
+ return result;
3948
+ }
3949
+ attachmentData = await response.arrayBuffer();
3950
+ }
3951
+ }
3952
+ const doc = await db.get(docId);
3953
+ await db.putAttachment(
3954
+ docId,
3955
+ attachmentName,
3956
+ doc._rev,
3957
+ attachmentData,
3958
+ // PouchDB accepts both ArrayBuffer and Buffer
3959
+ attachmentMeta.content_type
3960
+ );
3961
+ result.success = true;
3962
+ } catch (error) {
3963
+ result.error = error instanceof Error ? error.message : String(error);
3964
+ }
3965
+ return result;
3966
+ }
3967
+ /**
3968
+ * Restore CourseConfig document from manifest
3969
+ */
3970
+ async restoreCourseConfig(manifest, targetDB) {
3971
+ const results = {
3972
+ restored: 0,
3973
+ errors: [],
3974
+ warnings: []
3975
+ };
3976
+ try {
3977
+ if (!manifest.courseConfig) {
3978
+ results.warnings.push(
3979
+ "No courseConfig found in manifest, skipping CourseConfig document creation"
3980
+ );
3981
+ return results;
3982
+ }
3983
+ const courseConfigDoc = {
3984
+ _id: "CourseConfig",
3985
+ ...manifest.courseConfig,
3986
+ courseID: manifest.courseId
3987
+ };
3988
+ delete courseConfigDoc._rev;
3989
+ await targetDB.put(courseConfigDoc);
3990
+ results.restored = 1;
3991
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
3992
+ } catch (error) {
3993
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
3994
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
3995
+ logger.error("CourseConfig restoration failed:", error);
3996
+ }
3997
+ return results;
3998
+ }
3999
+ /**
4000
+ * Calculate expected document counts from manifest
4001
+ */
4002
+ calculateExpectedCounts(manifest) {
4003
+ const counts = {};
4004
+ for (const chunk of manifest.chunks) {
4005
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
4006
+ }
4007
+ if (manifest.designDocs.length > 0) {
4008
+ counts["_design"] = manifest.designDocs.length;
4009
+ }
4010
+ return counts;
4011
+ }
4012
+ /**
4013
+ * Clean up database after failed migration
4014
+ */
4015
+ async cleanupFailedMigration(db) {
4016
+ logger.info("Cleaning up failed migration...");
4017
+ try {
4018
+ const allDocs = await db.allDocs();
4019
+ const docsToDelete = allDocs.rows.map((row) => ({
4020
+ _id: row.id,
4021
+ _rev: row.value.rev,
4022
+ _deleted: true
4023
+ }));
4024
+ if (docsToDelete.length > 0) {
4025
+ await db.bulkDocs(docsToDelete);
4026
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
4027
+ }
4028
+ } catch (error) {
4029
+ logger.error("Failed to cleanup documents:", error);
4030
+ throw error;
4031
+ }
4032
+ }
4033
+ /**
4034
+ * Report progress to callback if available
4035
+ */
4036
+ reportProgress(phase, current, total, message) {
4037
+ if (this.progressCallback) {
4038
+ this.progressCallback({
4039
+ phase,
4040
+ current,
4041
+ total,
4042
+ message
4043
+ });
4044
+ }
4045
+ }
4046
+ /**
4047
+ * Check if a path is a local file path (vs URL)
4048
+ */
4049
+ isLocalPath(path2) {
4050
+ return !path2.startsWith("http://") && !path2.startsWith("https://");
4051
+ }
4052
+ };
4053
+ }
4054
+ });
4055
+
4056
+ // src/util/migrator/index.ts
4057
+ var init_migrator = __esm({
4058
+ "src/util/migrator/index.ts"() {
4059
+ "use strict";
4060
+ init_StaticToCouchDBMigrator();
4061
+ init_validation();
4062
+ init_FileSystemAdapter();
4063
+ }
4064
+ });
4065
+
4066
+ // src/util/dataDirectory.ts
4067
+ import * as path from "path";
4068
+ import * as os from "os";
4069
+ function getAppDataDirectory() {
4070
+ if (ENV.LOCAL_STORAGE_PREFIX) {
4071
+ return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
4072
+ } else {
4073
+ return path.join(os.homedir(), ".tuilder");
4074
+ }
4075
+ }
4076
+ function getDbPath(dbName) {
4077
+ return path.join(getAppDataDirectory(), dbName);
4078
+ }
4079
+ var init_dataDirectory = __esm({
4080
+ "src/util/dataDirectory.ts"() {
4081
+ "use strict";
4082
+ init_logger();
4083
+ init_factory();
4084
+ }
4085
+ });
4086
+
4087
+ // src/util/index.ts
4088
+ var init_util2 = __esm({
4089
+ "src/util/index.ts"() {
4090
+ "use strict";
4091
+ init_Loggable();
4092
+ init_packer();
4093
+ init_migrator();
4094
+ init_dataDirectory();
4095
+ }
4096
+ });
4097
+
4098
+ // src/study/SourceMixer.ts
4099
+ var init_SourceMixer = __esm({
4100
+ "src/study/SourceMixer.ts"() {
4101
+ "use strict";
4102
+ }
4103
+ });
4104
+
4105
+ // src/study/MixerDebugger.ts
4106
+ function printMixerSummary(run) {
4107
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
4108
+ logger.info(`Run ID: ${run.runId}`);
4109
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
4110
+ logger.info(
4111
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
4112
+ );
4113
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
4114
+ for (const src of run.sourceSummaries) {
4115
+ logger.info(
4116
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
4117
+ );
4118
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
4119
+ }
4120
+ console.groupEnd();
4121
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
4122
+ for (const breakdown of run.sourceBreakdowns) {
4123
+ const name = breakdown.sourceName || breakdown.sourceId;
4124
+ logger.info(
4125
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
4126
+ );
4127
+ }
4128
+ console.groupEnd();
4129
+ console.groupEnd();
4130
+ }
4131
+ function mountMixerDebugger() {
4132
+ if (typeof window === "undefined") return;
4133
+ const win = window;
4134
+ win.skuilder = win.skuilder || {};
4135
+ win.skuilder.mixer = mixerDebugAPI;
4136
+ }
4137
+ var runHistory2, mixerDebugAPI;
4138
+ var init_MixerDebugger = __esm({
4139
+ "src/study/MixerDebugger.ts"() {
4140
+ "use strict";
4141
+ init_logger();
4142
+ init_navigators();
4143
+ runHistory2 = [];
4144
+ mixerDebugAPI = {
4145
+ /**
4146
+ * Get raw run history for programmatic access.
4147
+ */
4148
+ get runs() {
4149
+ return [...runHistory2];
4150
+ },
4151
+ /**
4152
+ * Show summary of a specific mixer run.
4153
+ */
4154
+ showRun(idOrIndex = 0) {
4155
+ if (runHistory2.length === 0) {
4156
+ logger.info("[Mixer Debug] No runs captured yet.");
4157
+ return;
4158
+ }
4159
+ let run;
4160
+ if (typeof idOrIndex === "number") {
4161
+ run = runHistory2[idOrIndex];
4162
+ if (!run) {
4163
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
4164
+ return;
4165
+ }
4166
+ } else {
4167
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
4168
+ if (!run) {
4169
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
4170
+ return;
4171
+ }
4172
+ }
4173
+ printMixerSummary(run);
4174
+ },
4175
+ /**
4176
+ * Show summary of the last mixer run.
4177
+ */
4178
+ showLastMix() {
4179
+ this.showRun(0);
4180
+ },
4181
+ /**
4182
+ * Explain source balance in the last run.
4183
+ */
4184
+ explainSourceBalance() {
4185
+ if (runHistory2.length === 0) {
4186
+ logger.info("[Mixer Debug] No runs captured yet.");
4187
+ return;
4188
+ }
4189
+ const run = runHistory2[0];
4190
+ console.group("\u2696\uFE0F Source Balance Analysis");
4191
+ logger.info(`Mixer: ${run.mixerType}`);
4192
+ logger.info(`Requested limit: ${run.requestedLimit}`);
4193
+ if (run.quotaPerSource) {
4194
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
4195
+ }
4196
+ console.group("Input Distribution:");
4197
+ for (const src of run.sourceSummaries) {
4198
+ const name = src.sourceName || src.sourceId;
4199
+ logger.info(`${name}:`);
4200
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
4201
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
4202
+ }
4203
+ console.groupEnd();
4204
+ console.group("Selection Results:");
4205
+ for (const breakdown of run.sourceBreakdowns) {
4206
+ const name = breakdown.sourceName || breakdown.sourceId;
4207
+ logger.info(`${name}:`);
4208
+ logger.info(
4209
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
4210
+ );
4211
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
4212
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
4213
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
4214
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
4215
+ }
4216
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
4217
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
4218
+ }
4219
+ }
4220
+ console.groupEnd();
4221
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
4222
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
4223
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
4224
+ if (maxDeviation > 20) {
4225
+ logger.info(`
4226
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
4227
+ logger.info("Possible causes:");
4228
+ logger.info(" - Score range differences between sources");
4229
+ logger.info(" - One source has much better quality cards");
4230
+ logger.info(" - Different card availability (reviews vs new)");
4231
+ }
4232
+ console.groupEnd();
4233
+ },
4234
+ /**
4235
+ * Compare score distributions across sources.
4236
+ */
4237
+ compareScores() {
4238
+ if (runHistory2.length === 0) {
4239
+ logger.info("[Mixer Debug] No runs captured yet.");
4240
+ return;
4241
+ }
4242
+ const run = runHistory2[0];
4243
+ console.group("\u{1F4CA} Score Distribution Comparison");
4244
+ console.table(
4245
+ run.sourceSummaries.map((src) => ({
4246
+ source: src.sourceName || src.sourceId,
4247
+ cards: src.totalCards,
4248
+ min: src.bottomScore.toFixed(3),
4249
+ max: src.topScore.toFixed(3),
4250
+ avg: src.avgScore.toFixed(3),
4251
+ range: (src.topScore - src.bottomScore).toFixed(3)
4252
+ }))
4253
+ );
4254
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
4255
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
4256
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
4257
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
4258
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
4259
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
4260
+ logger.info(
4261
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
4262
+ );
4263
+ }
4264
+ console.groupEnd();
4265
+ },
4266
+ /**
4267
+ * Show detailed information for a specific card.
4268
+ */
4269
+ showCard(cardId) {
4270
+ for (const run of runHistory2) {
4271
+ const card = run.cards.find((c) => c.cardId === cardId);
4272
+ if (card) {
4273
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
4274
+ console.group(`\u{1F3B4} Card: ${cardId}`);
4275
+ logger.info(`Course: ${card.courseId}`);
4276
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
4277
+ logger.info(`Origin: ${card.origin}`);
4278
+ logger.info(`Score: ${card.score.toFixed(3)}`);
4279
+ if (card.rankInSource) {
4280
+ logger.info(`Rank in source: #${card.rankInSource}`);
4281
+ }
4282
+ if (card.rankInMix) {
4283
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
4284
+ }
4285
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
4286
+ if (!card.selected && card.rankInSource) {
4287
+ logger.info("\nWhy not selected:");
4288
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
4289
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
4290
+ }
4291
+ logger.info(" - Check score compared to selected cards using .showRun()");
4292
+ }
4293
+ console.groupEnd();
4294
+ return;
4295
+ }
4296
+ }
4297
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
4298
+ },
4299
+ /**
4300
+ * Show all runs in compact format.
4301
+ */
4302
+ listRuns() {
4303
+ if (runHistory2.length === 0) {
4304
+ logger.info("[Mixer Debug] No runs captured yet.");
4305
+ return;
4306
+ }
4307
+ console.table(
4308
+ runHistory2.map((r) => ({
4309
+ id: r.runId.slice(-8),
4310
+ time: r.timestamp.toLocaleTimeString(),
4311
+ mixer: r.mixerType,
4312
+ sources: r.sourceSummaries.length,
4313
+ selected: r.finalCount,
4314
+ reviews: r.reviewsSelected,
4315
+ new: r.newSelected
4316
+ }))
4317
+ );
4318
+ },
4319
+ /**
4320
+ * Export run history as JSON for bug reports.
4321
+ */
4322
+ export() {
4323
+ const json = JSON.stringify(runHistory2, null, 2);
4324
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
4325
+ logger.info(" copy(window.skuilder.mixer.export())");
4326
+ return json;
4327
+ },
4328
+ /**
4329
+ * Clear run history.
4330
+ */
4331
+ clear() {
4332
+ runHistory2.length = 0;
4333
+ logger.info("[Mixer Debug] Run history cleared.");
4334
+ },
4335
+ /**
4336
+ * Show help.
4337
+ */
4338
+ help() {
4339
+ logger.info(`
4340
+ \u{1F3A8} Mixer Debug API
4341
+
4342
+ Commands:
4343
+ .showLastMix() Show summary of most recent mixer run
4344
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
4345
+ .explainSourceBalance() Analyze source balance and selection patterns
4346
+ .compareScores() Compare score distributions across sources
4347
+ .showCard(cardId) Show mixer decisions for a specific card
4348
+ .listRuns() List all captured runs in table format
4349
+ .export() Export run history as JSON for bug reports
4350
+ .clear() Clear run history
4351
+ .runs Access raw run history array
4352
+ .help() Show this help message
2451
4353
 
2452
- // src/core/orchestration/learning.ts
2453
- var init_learning = __esm({
2454
- "src/core/orchestration/learning.ts"() {
2455
- "use strict";
2456
- init_contentNavigationStrategy();
2457
- init_types_legacy();
2458
- init_logger();
4354
+ Example:
4355
+ window.skuilder.mixer.showLastMix()
4356
+ window.skuilder.mixer.explainSourceBalance()
4357
+ window.skuilder.mixer.compareScores()
4358
+ `);
4359
+ }
4360
+ };
4361
+ mountMixerDebugger();
2459
4362
  }
2460
4363
  });
2461
4364
 
2462
- // src/core/orchestration/signal.ts
2463
- var init_signal = __esm({
2464
- "src/core/orchestration/signal.ts"() {
2465
- "use strict";
4365
+ // src/study/SessionDebugger.ts
4366
+ function showCurrentQueue() {
4367
+ if (!activeSession) {
4368
+ logger.info("[Session Debug] No active session.");
4369
+ return;
2466
4370
  }
2467
- });
2468
-
2469
- // src/core/orchestration/recording.ts
2470
- var init_recording = __esm({
2471
- "src/core/orchestration/recording.ts"() {
2472
- "use strict";
2473
- init_signal();
2474
- init_types_legacy();
2475
- init_logger();
4371
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
4372
+ console.group("\u{1F4CA} Current Queue State");
4373
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
4374
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
4375
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
2476
4376
  }
2477
- });
2478
-
2479
- // src/core/orchestration/index.ts
2480
- function fnv1a(str) {
2481
- let hash = 2166136261;
2482
- for (let i = 0; i < str.length; i++) {
2483
- hash ^= str.charCodeAt(i);
2484
- hash = Math.imul(hash, 16777619);
4377
+ logger.info(`New Queue: ${latest.newQLength} cards`);
4378
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
4379
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
2485
4380
  }
2486
- return hash >>> 0;
2487
- }
2488
- function computeDeviation(userId, strategyId, salt) {
2489
- const input = `${userId}:${strategyId}:${salt}`;
2490
- const hash = fnv1a(input);
2491
- const normalized = hash / 4294967296;
2492
- return normalized * 2 - 1;
2493
- }
2494
- function computeSpread(confidence) {
2495
- const clampedConfidence = Math.max(0, Math.min(1, confidence));
2496
- return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
4381
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
4382
+ console.groupEnd();
2497
4383
  }
2498
- function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2499
- const deviation = computeDeviation(userId, strategyId, salt);
2500
- const spread = computeSpread(learnable.confidence);
2501
- const adjustment = deviation * spread * learnable.weight;
2502
- const effective = learnable.weight + adjustment;
2503
- return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
4384
+ function showPresentationHistory(sessionIndex = 0) {
4385
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4386
+ if (!session) {
4387
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4388
+ return;
4389
+ }
4390
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
4391
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
4392
+ if (session.endTime) {
4393
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
4394
+ }
4395
+ logger.info(`Cards presented: ${session.presentations.length}`);
4396
+ if (session.presentations.length > 0) {
4397
+ console.table(
4398
+ session.presentations.map((p) => ({
4399
+ "#": p.sequenceNumber,
4400
+ course: p.courseName || p.courseId.slice(0, 8),
4401
+ origin: p.origin,
4402
+ queue: p.queueSource,
4403
+ score: p.score?.toFixed(3) || "-",
4404
+ time: p.timestamp.toLocaleTimeString()
4405
+ }))
4406
+ );
4407
+ }
4408
+ console.groupEnd();
2504
4409
  }
2505
- async function createOrchestrationContext(user, course) {
2506
- let courseConfig;
2507
- try {
2508
- courseConfig = await course.getCourseConfig();
2509
- } catch (e) {
2510
- logger.error(`[Orchestration] Failed to load course config: ${e}`);
2511
- courseConfig = {
2512
- name: "Unknown",
2513
- description: "",
2514
- public: false,
2515
- deleted: false,
2516
- creator: "",
2517
- admins: [],
2518
- moderators: [],
2519
- dataShapes: [],
2520
- questionTypes: [],
2521
- orchestration: { salt: "default" }
2522
- };
4410
+ function showInterleaving(sessionIndex = 0) {
4411
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4412
+ if (!session) {
4413
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4414
+ return;
2523
4415
  }
2524
- const userId = user.getUsername();
2525
- const salt = courseConfig.orchestration?.salt || "default_salt";
2526
- return {
2527
- user,
2528
- course,
2529
- userId,
2530
- courseConfig,
2531
- getEffectiveWeight(strategyId, learnable) {
2532
- return computeEffectiveWeight(learnable, userId, strategyId, salt);
2533
- },
2534
- getDeviation(strategyId) {
2535
- return computeDeviation(userId, strategyId, salt);
4416
+ console.group("\u{1F500} Interleaving Analysis");
4417
+ const courseCounts = /* @__PURE__ */ new Map();
4418
+ const courseOrigins = /* @__PURE__ */ new Map();
4419
+ session.presentations.forEach((p) => {
4420
+ const name = p.courseName || p.courseId;
4421
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
4422
+ if (!courseOrigins.has(name)) {
4423
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
2536
4424
  }
2537
- };
4425
+ const origins = courseOrigins.get(name);
4426
+ origins[p.origin]++;
4427
+ });
4428
+ logger.info("Course distribution:");
4429
+ console.table(
4430
+ Array.from(courseCounts.entries()).map(([course, count]) => {
4431
+ const origins = courseOrigins.get(course);
4432
+ return {
4433
+ course,
4434
+ total: count,
4435
+ reviews: origins.review,
4436
+ new: origins.new,
4437
+ failed: origins.failed,
4438
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
4439
+ };
4440
+ })
4441
+ );
4442
+ if (session.presentations.length > 0) {
4443
+ logger.info("\nPresentation sequence (first 20):");
4444
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
4445
+ logger.info(sequence);
4446
+ }
4447
+ let maxCluster = 0;
4448
+ let currentCluster = 1;
4449
+ let currentCourse = session.presentations[0]?.courseId;
4450
+ for (let i = 1; i < session.presentations.length; i++) {
4451
+ if (session.presentations[i].courseId === currentCourse) {
4452
+ currentCluster++;
4453
+ maxCluster = Math.max(maxCluster, currentCluster);
4454
+ } else {
4455
+ currentCourse = session.presentations[i].courseId;
4456
+ currentCluster = 1;
4457
+ }
4458
+ }
4459
+ if (maxCluster > 3) {
4460
+ logger.info(`
4461
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
4462
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
4463
+ }
4464
+ console.groupEnd();
2538
4465
  }
2539
- var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2540
- var init_orchestration = __esm({
2541
- "src/core/orchestration/index.ts"() {
4466
+ function mountSessionDebugger() {
4467
+ if (typeof window === "undefined") return;
4468
+ const win = window;
4469
+ win.skuilder = win.skuilder || {};
4470
+ win.skuilder.session = sessionDebugAPI;
4471
+ }
4472
+ var activeSession, sessionHistory, sessionDebugAPI;
4473
+ var init_SessionDebugger = __esm({
4474
+ "src/study/SessionDebugger.ts"() {
2542
4475
  "use strict";
2543
4476
  init_logger();
2544
- init_gradient();
2545
- init_learning();
2546
- init_signal();
4477
+ activeSession = null;
4478
+ sessionHistory = [];
4479
+ sessionDebugAPI = {
4480
+ /**
4481
+ * Get raw session history for programmatic access.
4482
+ */
4483
+ get sessions() {
4484
+ return [...sessionHistory];
4485
+ },
4486
+ /**
4487
+ * Get active session if any.
4488
+ */
4489
+ get active() {
4490
+ return activeSession;
4491
+ },
4492
+ /**
4493
+ * Show current queue state.
4494
+ */
4495
+ showQueue() {
4496
+ showCurrentQueue();
4497
+ },
4498
+ /**
4499
+ * Show presentation history for current or past session.
4500
+ */
4501
+ showHistory(sessionIndex = 0) {
4502
+ showPresentationHistory(sessionIndex);
4503
+ },
4504
+ /**
4505
+ * Analyze course interleaving pattern.
4506
+ */
4507
+ showInterleaving(sessionIndex = 0) {
4508
+ showInterleaving(sessionIndex);
4509
+ },
4510
+ /**
4511
+ * List all tracked sessions.
4512
+ */
4513
+ listSessions() {
4514
+ if (activeSession) {
4515
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
4516
+ }
4517
+ if (sessionHistory.length === 0) {
4518
+ logger.info("[Session Debug] No completed sessions in history.");
4519
+ return;
4520
+ }
4521
+ console.table(
4522
+ sessionHistory.map((s, idx) => ({
4523
+ index: idx,
4524
+ id: s.sessionId.slice(-8),
4525
+ started: s.startTime.toLocaleTimeString(),
4526
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
4527
+ cards: s.presentations.length
4528
+ }))
4529
+ );
4530
+ },
4531
+ /**
4532
+ * Export session history as JSON for bug reports.
4533
+ */
4534
+ export() {
4535
+ const data = {
4536
+ active: activeSession,
4537
+ history: sessionHistory
4538
+ };
4539
+ const json = JSON.stringify(data, null, 2);
4540
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
4541
+ logger.info(" copy(window.skuilder.session.export())");
4542
+ return json;
4543
+ },
4544
+ /**
4545
+ * Clear session history.
4546
+ */
4547
+ clear() {
4548
+ sessionHistory.length = 0;
4549
+ logger.info("[Session Debug] Session history cleared.");
4550
+ },
4551
+ /**
4552
+ * Show help.
4553
+ */
4554
+ help() {
4555
+ logger.info(`
4556
+ \u{1F3AF} Session Debug API
4557
+
4558
+ Commands:
4559
+ .showQueue() Show current queue state (active session only)
4560
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
4561
+ .showInterleaving(index?) Analyze course interleaving pattern
4562
+ .listSessions() List all tracked sessions
4563
+ .export() Export session data as JSON for bug reports
4564
+ .clear() Clear session history
4565
+ .sessions Access raw session history array
4566
+ .active Access active session (if any)
4567
+ .help() Show this help message
4568
+
4569
+ Example:
4570
+ window.skuilder.session.showHistory()
4571
+ window.skuilder.session.showInterleaving()
4572
+ window.skuilder.session.showQueue()
4573
+ `);
4574
+ }
4575
+ };
4576
+ mountSessionDebugger();
4577
+ }
4578
+ });
4579
+
4580
+ // src/study/SessionController.ts
4581
+ var init_SessionController = __esm({
4582
+ "src/study/SessionController.ts"() {
4583
+ "use strict";
4584
+ init_SrsService();
4585
+ init_EloService();
4586
+ init_ResponseProcessor();
4587
+ init_CardHydrationService();
4588
+ init_ItemQueue();
4589
+ init_couch();
2547
4590
  init_recording();
2548
- MIN_SPREAD = 0.1;
2549
- MAX_SPREAD = 0.5;
2550
- MIN_WEIGHT = 0.1;
2551
- MAX_WEIGHT = 3;
4591
+ init_util2();
4592
+ init_navigators();
4593
+ init_SourceMixer();
4594
+ init_MixerDebugger();
4595
+ init_SessionDebugger();
4596
+ init_logger();
2552
4597
  }
2553
4598
  });
2554
4599
 
@@ -2557,7 +4602,7 @@ var Pipeline_exports = {};
2557
4602
  __export(Pipeline_exports, {
2558
4603
  Pipeline: () => Pipeline
2559
4604
  });
2560
- import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
4605
+ import { toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
2561
4606
  function globToRegex(pattern) {
2562
4607
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2563
4608
  const withWildcards = escaped.replace(/\*/g, ".*");
@@ -2641,6 +4686,7 @@ var init_Pipeline = __esm({
2641
4686
  init_logger();
2642
4687
  init_orchestration();
2643
4688
  init_PipelineDebugger();
4689
+ init_SessionController();
2644
4690
  VERBOSE_RESULTS = true;
2645
4691
  Pipeline = class extends ContentNavigator {
2646
4692
  generator;
@@ -2800,8 +4846,9 @@ var init_Pipeline = __esm({
2800
4846
  generatorSummaries,
2801
4847
  generatedCount,
2802
4848
  filterImpacts,
2803
- allCardsBeforeFiltering,
2804
- result
4849
+ cards,
4850
+ result,
4851
+ context.userElo
2805
4852
  );
2806
4853
  captureRun(report);
2807
4854
  } catch (e) {
@@ -2962,7 +5009,7 @@ var init_Pipeline = __esm({
2962
5009
  let userElo = 1e3;
2963
5010
  try {
2964
5011
  const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
2965
- const courseElo = toCourseElo5(courseReg.elo);
5012
+ const courseElo = toCourseElo7(courseReg.elo);
2966
5013
  userElo = courseElo.global.score;
2967
5014
  } catch (e) {
2968
5015
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
@@ -3015,6 +5062,34 @@ var init_Pipeline = __esm({
3015
5062
  return [...new Set(ids)];
3016
5063
  }
3017
5064
  // ---------------------------------------------------------------------------
5065
+ // Tag ELO diagnostic
5066
+ // ---------------------------------------------------------------------------
5067
+ /**
5068
+ * Get the user's per-tag ELO data for specified tags (or all tags).
5069
+ * Useful for diagnosing why hierarchy gates are open/closed.
5070
+ */
5071
+ async getTagEloStatus(tagFilter) {
5072
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
5073
+ const courseElo = toCourseElo7(courseReg.elo);
5074
+ const result = {};
5075
+ if (!tagFilter) {
5076
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5077
+ result[tag] = { score: data.score, count: data.count };
5078
+ }
5079
+ } else {
5080
+ const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
5081
+ for (const pattern of patterns) {
5082
+ const regex = globToRegex(pattern);
5083
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5084
+ if (regex.test(tag)) {
5085
+ result[tag] = { score: data.score, count: data.count };
5086
+ }
5087
+ }
5088
+ }
5089
+ }
5090
+ return result;
5091
+ }
5092
+ // ---------------------------------------------------------------------------
3018
5093
  // Card-space diagnostic
3019
5094
  // ---------------------------------------------------------------------------
3020
5095
  /**
@@ -3597,7 +5672,7 @@ import {
3597
5672
  EloToNumber,
3598
5673
  Status,
3599
5674
  blankCourseElo as blankCourseElo2,
3600
- toCourseElo as toCourseElo6
5675
+ toCourseElo as toCourseElo8
3601
5676
  } from "@vue-skuilder/common";
3602
5677
  function randIntWeightedTowardZero(n) {
3603
5678
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -3868,7 +5943,7 @@ var init_courseDB = __esm({
3868
5943
  docs.rows.forEach((r) => {
3869
5944
  if (isSuccessRow(r)) {
3870
5945
  if (r.doc && r.doc.elo) {
3871
- ret.push(toCourseElo6(r.doc.elo));
5946
+ ret.push(toCourseElo8(r.doc.elo));
3872
5947
  } else {
3873
5948
  logger.warn("no elo data for card: " + r.id);
3874
5949
  ret.push(blankCourseElo2());
@@ -4373,7 +6448,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4373
6448
  });
4374
6449
 
4375
6450
  // src/impl/couch/classroomDB.ts
4376
- import moment2 from "moment";
6451
+ import moment4 from "moment";
4377
6452
  function getClassroomDB(classID, version) {
4378
6453
  const dbName = `classdb-${version}-${classID}`;
4379
6454
  logger.info(`Retrieving classroom db: ${dbName}`);
@@ -4503,9 +6578,9 @@ var init_classroomDB2 = __esm({
4503
6578
  }
4504
6579
  const activeCards = await this._user.getActiveCards();
4505
6580
  const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
4506
- const now = moment2.utc();
6581
+ const now = moment4.utc();
4507
6582
  const assigned = await this.getAssignedContent();
4508
- const due = assigned.filter((c) => now.isAfter(moment2.utc(c.activeOn, REVIEW_TIME_FORMAT)));
6583
+ const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT)));
4509
6584
  logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
4510
6585
  for (const content of due) {
4511
6586
  if (content.type === "course") {
@@ -4631,8 +6706,8 @@ var init_classroomDB2 = __esm({
4631
6706
  type: "tag",
4632
6707
  _id: id,
4633
6708
  assignedBy: content.assignedBy,
4634
- assignedOn: moment2.utc(),
4635
- activeOn: content.activeOn || moment2.utc()
6709
+ assignedOn: moment4.utc(),
6710
+ activeOn: content.activeOn || moment4.utc()
4636
6711
  });
4637
6712
  } else {
4638
6713
  put = await this._db.put({
@@ -4640,8 +6715,8 @@ var init_classroomDB2 = __esm({
4640
6715
  type: "course",
4641
6716
  _id: id,
4642
6717
  assignedBy: content.assignedBy,
4643
- assignedOn: moment2.utc(),
4644
- activeOn: content.activeOn || moment2.utc()
6718
+ assignedOn: moment4.utc(),
6719
+ activeOn: content.activeOn || moment4.utc()
4645
6720
  });
4646
6721
  }
4647
6722
  if (put.ok) {
@@ -4913,17 +6988,6 @@ var init_userOutcome = __esm({
4913
6988
  }
4914
6989
  });
4915
6990
 
4916
- // src/core/util/index.ts
4917
- function getCardHistoryID(courseID, cardID) {
4918
- return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
4919
- }
4920
- var init_util = __esm({
4921
- "src/core/util/index.ts"() {
4922
- "use strict";
4923
- init_types_legacy();
4924
- }
4925
- });
4926
-
4927
6991
  // src/core/bulkImport/cardProcessor.ts
4928
6992
  import { Status as Status2 } from "@vue-skuilder/common";
4929
6993
  var init_cardProcessor = __esm({
@@ -4934,7 +6998,7 @@ var init_cardProcessor = __esm({
4934
6998
  });
4935
6999
 
4936
7000
  // src/core/bulkImport/types.ts
4937
- var init_types3 = __esm({
7001
+ var init_types5 = __esm({
4938
7002
  "src/core/bulkImport/types.ts"() {
4939
7003
  "use strict";
4940
7004
  }
@@ -4945,33 +7009,12 @@ var init_bulkImport = __esm({
4945
7009
  "src/core/bulkImport/index.ts"() {
4946
7010
  "use strict";
4947
7011
  init_cardProcessor();
4948
- init_types3();
4949
- }
4950
- });
4951
-
4952
- // src/util/dataDirectory.ts
4953
- import * as path from "path";
4954
- import * as os from "os";
4955
- function getAppDataDirectory() {
4956
- if (ENV.LOCAL_STORAGE_PREFIX) {
4957
- return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
4958
- } else {
4959
- return path.join(os.homedir(), ".tuilder");
4960
- }
4961
- }
4962
- function getDbPath(dbName) {
4963
- return path.join(getAppDataDirectory(), dbName);
4964
- }
4965
- var init_dataDirectory = __esm({
4966
- "src/util/dataDirectory.ts"() {
4967
- "use strict";
4968
- init_logger();
4969
- init_factory();
7012
+ init_types5();
4970
7013
  }
4971
7014
  });
4972
7015
 
4973
7016
  // src/impl/common/userDBHelpers.ts
4974
- import moment3 from "moment";
7017
+ import moment5 from "moment";
4975
7018
  function hexEncode(str) {
4976
7019
  let hex;
4977
7020
  let returnStr = "";
@@ -4999,7 +7042,7 @@ function getStartAndEndKeys2(key) {
4999
7042
  };
5000
7043
  }
5001
7044
  function updateGuestAccountExpirationDate(guestDB) {
5002
- const currentTime = moment3.utc();
7045
+ const currentTime = moment5.utc();
5003
7046
  const expirationDate = currentTime.add(2, "months").toISOString();
5004
7047
  const expiryDocID2 = "GuestAccountExpirationDate";
5005
7048
  void guestDB.get(expiryDocID2).then((doc) => {
@@ -5024,7 +7067,7 @@ function getLocalUserDB(username) {
5024
7067
  }
5025
7068
  }
5026
7069
  function scheduleCardReviewLocal(userDB, review) {
5027
- const now = moment3.utc();
7070
+ const now = moment5.utc();
5028
7071
  logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
5029
7072
  void userDB.put({
5030
7073
  _id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT2),
@@ -5410,7 +7453,7 @@ var init_core = __esm({
5410
7453
  });
5411
7454
 
5412
7455
  // src/impl/couch/user-course-relDB.ts
5413
- import moment4 from "moment";
7456
+ import moment6 from "moment";
5414
7457
  var UsrCrsData;
5415
7458
  var init_user_course_relDB = __esm({
5416
7459
  "src/impl/couch/user-course-relDB.ts"() {
@@ -5424,11 +7467,11 @@ var init_user_course_relDB = __esm({
5424
7467
  this._courseId = courseId;
5425
7468
  }
5426
7469
  async getReviewsForcast(daysCount) {
5427
- const time = moment4.utc().add(daysCount, "days");
7470
+ const time = moment6.utc().add(daysCount, "days");
5428
7471
  return this.getReviewstoDate(time);
5429
7472
  }
5430
7473
  async getPendingReviews() {
5431
- const now = moment4.utc();
7474
+ const now = moment6.utc();
5432
7475
  return this.getReviewstoDate(now);
5433
7476
  }
5434
7477
  async getScheduledReviewCount() {
@@ -5464,7 +7507,7 @@ var init_user_course_relDB = __esm({
5464
7507
  `Fetching ${this.user.getUsername()}'s scheduled reviews for course ${this._courseId}.`
5465
7508
  );
5466
7509
  return allReviews.filter((review) => {
5467
- const reviewTime = moment4.utc(review.reviewTime);
7510
+ const reviewTime = moment6.utc(review.reviewTime);
5468
7511
  return targetDate.isAfter(reviewTime);
5469
7512
  });
5470
7513
  }
@@ -5474,7 +7517,7 @@ var init_user_course_relDB = __esm({
5474
7517
 
5475
7518
  // src/impl/common/BaseUserDB.ts
5476
7519
  import { Status as Status3 } from "@vue-skuilder/common";
5477
- import moment5 from "moment";
7520
+ import moment7 from "moment";
5478
7521
  function accomodateGuest() {
5479
7522
  logger.log("[funnel] accomodateGuest() called");
5480
7523
  if (typeof localStorage === "undefined") {
@@ -5917,7 +7960,7 @@ Currently logged-in as ${this._username}.`
5917
7960
  );
5918
7961
  return reviews.rows.filter((r) => {
5919
7962
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
5920
- const date = moment5.utc(
7963
+ const date = moment7.utc(
5921
7964
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
5922
7965
  REVIEW_TIME_FORMAT2
5923
7966
  );
@@ -5930,11 +7973,11 @@ Currently logged-in as ${this._username}.`
5930
7973
  }).map((r) => r.doc);
5931
7974
  }
5932
7975
  async getReviewsForcast(daysCount) {
5933
- const time = moment5.utc().add(daysCount, "days");
7976
+ const time = moment7.utc().add(daysCount, "days");
5934
7977
  return this.getReviewstoDate(time);
5935
7978
  }
5936
7979
  async getPendingReviews(course_id) {
5937
- const now = moment5.utc();
7980
+ const now = moment7.utc();
5938
7981
  return this.getReviewstoDate(now, course_id);
5939
7982
  }
5940
7983
  async getScheduledReviewCount(course_id) {
@@ -6221,7 +8264,7 @@ Currently logged-in as ${this._username}.`
6221
8264
  */
6222
8265
  async putCardRecord(record) {
6223
8266
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
6224
- record.timeStamp = moment5.utc(record.timeStamp).toString();
8267
+ record.timeStamp = moment7.utc(record.timeStamp).toString();
6225
8268
  try {
6226
8269
  const cardHistory = await this.update(
6227
8270
  cardHistoryID,
@@ -6237,7 +8280,7 @@ Currently logged-in as ${this._username}.`
6237
8280
  const ret = {
6238
8281
  ...record2
6239
8282
  };
6240
- ret.timeStamp = moment5.utc(record2.timeStamp);
8283
+ ret.timeStamp = moment7.utc(record2.timeStamp);
6241
8284
  return ret;
6242
8285
  });
6243
8286
  return cardHistory;
@@ -6692,8 +8735,20 @@ var init_CourseSyncService = __esm({
6692
8735
  */
6693
8736
  async ensureSynced(courseId, forceEnabled) {
6694
8737
  const existing = this.entries.get(courseId);
6695
- if (existing?.status.state === "ready") {
6696
- return;
8738
+ if (existing?.status.state === "ready" && existing.localDB) {
8739
+ const stale = await this.isLocalEpochStale(courseId, existing.localDB);
8740
+ if (!stale) {
8741
+ return;
8742
+ }
8743
+ logger.info(
8744
+ `[CourseSyncService] Remote DB epoch changed for course ${courseId} \u2014 destroying stale local replica`
8745
+ );
8746
+ try {
8747
+ await existing.localDB.destroy();
8748
+ } catch {
8749
+ }
8750
+ existing.localDB = null;
8751
+ existing.readyPromise = null;
6697
8752
  }
6698
8753
  if (existing?.status.state === "disabled") {
6699
8754
  return;
@@ -6757,7 +8812,15 @@ var init_CourseSyncService = __esm({
6757
8812
  }
6758
8813
  entry.status = { state: "syncing" };
6759
8814
  const localDBName = this.localDBName(courseId);
6760
- const localDB = new pouchdb_setup_default(localDBName);
8815
+ let localDB = new pouchdb_setup_default(localDBName);
8816
+ const stale = await this.isLocalEpochStale(courseId, localDB);
8817
+ if (stale) {
8818
+ logger.info(
8819
+ `[CourseSyncService] Stale local DB detected for course ${courseId} \u2014 destroying before sync`
8820
+ );
8821
+ await localDB.destroy();
8822
+ localDB = new pouchdb_setup_default(localDBName);
8823
+ }
6761
8824
  entry.localDB = localDB;
6762
8825
  const remoteDB = this.getRemoteDB(courseId);
6763
8826
  const syncStart = Date.now();
@@ -6847,6 +8910,33 @@ var init_CourseSyncService = __esm({
6847
8910
  }
6848
8911
  }
6849
8912
  }
8913
+ /**
8914
+ * Check whether the local replica's `db-epoch` doc matches the remote.
8915
+ *
8916
+ * The seed script (and optionally upload-cards) writes a `db-epoch`
8917
+ * document with a numeric timestamp. If the remote epoch differs from
8918
+ * the local copy, the remote DB was recreated (e.g., `yarn db:seed`)
8919
+ * and the local PouchDB is stale.
8920
+ *
8921
+ * Returns `true` if stale (epoch mismatch or remote has epoch but local
8922
+ * doesn't). Returns `false` (not stale) if epochs match, or if the
8923
+ * remote doesn't have an epoch doc at all (backwards compat).
8924
+ */
8925
+ async isLocalEpochStale(courseId, localDB) {
8926
+ try {
8927
+ const remoteDB = this.getRemoteDB(courseId);
8928
+ const remoteEpoch = await remoteDB.get("db-epoch");
8929
+ let localEpoch = null;
8930
+ try {
8931
+ localEpoch = await localDB.get("db-epoch");
8932
+ } catch {
8933
+ return true;
8934
+ }
8935
+ return remoteEpoch.epoch !== localEpoch.epoch;
8936
+ } catch {
8937
+ return false;
8938
+ }
8939
+ }
6850
8940
  /**
6851
8941
  * Get a remote PouchDB handle for a course.
6852
8942
  */
@@ -6864,7 +8954,7 @@ var init_CourseSyncService = __esm({
6864
8954
  });
6865
8955
 
6866
8956
  // src/impl/couch/auth.ts
6867
- import fetch from "cross-fetch";
8957
+ import fetch2 from "cross-fetch";
6868
8958
  async function getCurrentSession() {
6869
8959
  try {
6870
8960
  if (ENV.COUCHDB_SERVER_URL === NOT_SET || ENV.COUCHDB_SERVER_PROTOCOL === NOT_SET) {
@@ -6873,7 +8963,7 @@ async function getCurrentSession() {
6873
8963
  const baseUrl = ENV.COUCHDB_SERVER_URL.endsWith("/") ? ENV.COUCHDB_SERVER_URL.slice(0, -1) : ENV.COUCHDB_SERVER_URL;
6874
8964
  const url = `${ENV.COUCHDB_SERVER_PROTOCOL}://${baseUrl}/_session`;
6875
8965
  logger.debug(`Attempting session check at: ${url}`);
6876
- const response = await fetch(url, {
8966
+ const response = await fetch2(url, {
6877
8967
  method: "GET",
6878
8968
  credentials: "include"
6879
8969
  });
@@ -7131,8 +9221,8 @@ var init_CouchDBSyncStrategy = __esm({
7131
9221
  });
7132
9222
 
7133
9223
  // src/impl/couch/index.ts
7134
- import fetch2 from "cross-fetch";
7135
- import moment6 from "moment";
9224
+ import fetch3 from "cross-fetch";
9225
+ import moment8 from "moment";
7136
9226
  import process2 from "process";
7137
9227
  function hexEncode2(str) {
7138
9228
  let hex;
@@ -7193,7 +9283,7 @@ async function usernameIsAvailable(username) {
7193
9283
  log(`Checking availability of ${username}`);
7194
9284
  try {
7195
9285
  const url = ENV.COUCHDB_SERVER_URL + "userdb-" + hexEncode2(username);
7196
- const response = await fetch2(url, { method: "HEAD" });
9286
+ const response = await fetch3(url, { method: "HEAD" });
7197
9287
  return response.status === 404;
7198
9288
  } catch (error) {
7199
9289
  log(`Error checking username availability: ${error}`);
@@ -7201,7 +9291,7 @@ async function usernameIsAvailable(username) {
7201
9291
  }
7202
9292
  }
7203
9293
  function updateGuestAccountExpirationDate2(guestDB) {
7204
- const currentTime = moment6.utc();
9294
+ const currentTime = moment8.utc();
7205
9295
  const expirationDate = currentTime.add(2, "months").toISOString();
7206
9296
  void guestDB.get(expiryDocID).then((doc) => {
7207
9297
  return guestDB.put({
@@ -7264,7 +9354,7 @@ function getCouchUserDB(username) {
7264
9354
  return ret;
7265
9355
  }
7266
9356
  function scheduleCardReview(review) {
7267
- const now = moment6.utc();
9357
+ const now = moment8.utc();
7268
9358
  logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
7269
9359
  void getCouchUserDB(review.user).put({
7270
9360
  _id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT),