@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
@@ -674,13 +674,20 @@ function captureRun(report) {
674
674
  runHistory.pop();
675
675
  }
676
676
  }
677
- function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
677
+ function parseCardElo(provenance) {
678
+ const eloEntry = provenance.find((p) => p.strategy === "elo");
679
+ if (!eloEntry?.reason) return void 0;
680
+ const match = eloEntry.reason.match(/card:\s*(\d+)/);
681
+ return match ? parseInt(match[1], 10) : void 0;
682
+ }
683
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
678
684
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
679
685
  const cards = allCards.map((card) => ({
680
686
  cardId: card.cardId,
681
687
  courseId: card.courseId,
682
688
  origin: getOrigin(card),
683
689
  finalScore: card.score,
690
+ cardElo: parseCardElo(card.provenance),
684
691
  provenance: card.provenance,
685
692
  tags: card.tags,
686
693
  selected: selectedIds.has(card.cardId)
@@ -690,6 +697,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
690
697
  return {
691
698
  courseId,
692
699
  courseName,
700
+ userElo,
693
701
  generatorName,
694
702
  generators,
695
703
  generatedCount,
@@ -710,6 +718,7 @@ function printRunSummary(run) {
710
718
  console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
711
719
  logger.info(`Run ID: ${run.runId}`);
712
720
  logger.info(`Time: ${run.timestamp.toISOString()}`);
721
+ logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
713
722
  logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
714
723
  if (run.generators && run.generators.length > 0) {
715
724
  console.group("Generator breakdown:");
@@ -796,8 +805,12 @@ var init_PipelineDebugger = __esm({
796
805
  console.group(`\u{1F3B4} Card: ${cardId}`);
797
806
  logger.info(`Course: ${card.courseId}`);
798
807
  logger.info(`Origin: ${card.origin}`);
808
+ logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
799
809
  logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
800
810
  logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
811
+ if (card.tags && card.tags.length > 0) {
812
+ logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
813
+ }
801
814
  logger.info("Provenance:");
802
815
  logger.info(formatProvenance(card.provenance));
803
816
  console.groupEnd();
@@ -961,6 +974,27 @@ var init_PipelineDebugger = __esm({
961
974
  }
962
975
  return _activePipeline.diagnoseCardSpace({ threshold });
963
976
  },
977
+ /**
978
+ * Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
979
+ *
980
+ * @param tagFilter - Optional glob pattern(s) to filter tags.
981
+ * Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
982
+ */
983
+ async showTagElo(tagFilter) {
984
+ if (!_activePipeline) {
985
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
986
+ return;
987
+ }
988
+ const status = await _activePipeline.getTagEloStatus(tagFilter);
989
+ const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
990
+ if (entries.length === 0) {
991
+ logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
992
+ return;
993
+ }
994
+ console.table(
995
+ Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
996
+ );
997
+ },
964
998
  /**
965
999
  * Show help.
966
1000
  */
@@ -972,6 +1006,7 @@ Commands:
972
1006
  .showLastRun() Show summary of most recent pipeline run
973
1007
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
974
1008
  .showCard(cardId) Show provenance trail for a specific card
1009
+ .showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
975
1010
  .explainReviews() Analyze why reviews were/weren't selected
976
1011
  .diagnoseCardSpace() Scan full card space through filters (async)
977
1012
  .showRegistry() Show navigator registry (classes + roles)
@@ -1285,60 +1320,423 @@ var prescribed_exports = {};
1285
1320
  __export(prescribed_exports, {
1286
1321
  default: () => PrescribedCardsGenerator
1287
1322
  });
1288
- var PrescribedCardsGenerator;
1323
+ function dedupe(arr) {
1324
+ return [...new Set(arr)];
1325
+ }
1326
+ function isoNow() {
1327
+ return (/* @__PURE__ */ new Date()).toISOString();
1328
+ }
1329
+ function clamp(value, min, max) {
1330
+ return Math.max(min, Math.min(max, value));
1331
+ }
1332
+ function matchesTagPattern(tag, pattern) {
1333
+ if (pattern === "*") return true;
1334
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1335
+ const re = new RegExp(`^${escaped}$`);
1336
+ return re.test(tag);
1337
+ }
1338
+ function pickTopByScore(cards, limit) {
1339
+ return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1340
+ }
1341
+ 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;
1289
1342
  var init_prescribed = __esm({
1290
1343
  "src/core/navigators/generators/prescribed.ts"() {
1291
1344
  "use strict";
1292
1345
  init_navigators();
1293
1346
  init_logger();
1347
+ DEFAULT_FRESHNESS_WINDOW = 3;
1348
+ DEFAULT_MAX_DIRECT_PER_RUN = 3;
1349
+ DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1350
+ DEFAULT_HIERARCHY_DEPTH = 2;
1351
+ DEFAULT_MIN_COUNT = 3;
1352
+ BASE_TARGET_SCORE = 1;
1353
+ BASE_SUPPORT_SCORE = 0.8;
1354
+ MAX_TARGET_MULTIPLIER = 8;
1355
+ MAX_SUPPORT_MULTIPLIER = 4;
1356
+ LOCKED_TAG_PREFIXES = ["concept:"];
1357
+ LESSON_GATE_PENALTY_TAG_HINT = "concept:";
1294
1358
  PrescribedCardsGenerator = class extends ContentNavigator {
1295
1359
  name;
1296
1360
  config;
1297
1361
  constructor(user, course, strategyData) {
1298
1362
  super(user, course, strategyData);
1299
1363
  this.name = strategyData.name || "Prescribed Cards";
1300
- try {
1301
- const parsed = JSON.parse(strategyData.serializedData);
1302
- this.config = { cardIds: parsed.cardIds || [] };
1303
- } catch {
1304
- this.config = { cardIds: [] };
1305
- }
1364
+ this.config = this.parseConfig(strategyData.serializedData);
1306
1365
  logger.debug(
1307
- `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1366
+ `[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
1308
1367
  );
1309
1368
  }
1310
- async getWeightedCards(limit, _context) {
1311
- if (this.config.cardIds.length === 0) {
1369
+ get strategyKey() {
1370
+ return "PrescribedProgress";
1371
+ }
1372
+ async getWeightedCards(limit, context) {
1373
+ if (this.config.groups.length === 0 || limit <= 0) {
1312
1374
  return [];
1313
1375
  }
1314
1376
  const courseId = this.course.getCourseID();
1315
1377
  const activeCards = await this.user.getActiveCards();
1316
1378
  const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1317
- const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1318
- if (eligibleIds.length === 0) {
1319
- logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1379
+ const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
1380
+ const seenIds = new Set(seenCards);
1381
+ const progress = await this.getStrategyState() ?? {
1382
+ updatedAt: isoNow(),
1383
+ groups: {}
1384
+ };
1385
+ const hierarchyConfigs = await this.loadHierarchyConfigs();
1386
+ const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
1387
+ const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
1388
+ const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
1389
+ const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
1390
+ const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
1391
+ const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
1392
+ const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
1393
+ const nextState = {
1394
+ updatedAt: isoNow(),
1395
+ groups: {}
1396
+ };
1397
+ const emitted = [];
1398
+ const emittedIds = /* @__PURE__ */ new Set();
1399
+ for (const group of this.config.groups) {
1400
+ const runtime = this.buildGroupRuntimeState({
1401
+ group,
1402
+ priorState: progress.groups[group.id],
1403
+ activeIds,
1404
+ seenIds,
1405
+ tagsByCard,
1406
+ hierarchyConfigs,
1407
+ userTagElo,
1408
+ userGlobalElo
1409
+ });
1410
+ nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
1411
+ const directCards = this.buildDirectTargetCards(
1412
+ runtime,
1413
+ courseId,
1414
+ emittedIds
1415
+ );
1416
+ const supportCards = this.buildSupportCards(
1417
+ runtime,
1418
+ courseId,
1419
+ emittedIds
1420
+ );
1421
+ emitted.push(...directCards, ...supportCards);
1422
+ }
1423
+ if (emitted.length === 0) {
1424
+ logger.debug("[Prescribed] No prescribed targets/support emitted this run");
1425
+ await this.putStrategyState(nextState).catch((e) => {
1426
+ logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
1427
+ });
1320
1428
  return [];
1321
1429
  }
1322
- const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1323
- cardId,
1324
- courseId,
1325
- score: 1,
1326
- provenance: [
1327
- {
1328
- strategy: "prescribed",
1329
- strategyName: this.strategyName || this.name,
1330
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1331
- action: "generated",
1332
- score: 1,
1333
- reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1430
+ const finalCards = pickTopByScore(emitted, limit);
1431
+ const surfacedByGroup = /* @__PURE__ */ new Map();
1432
+ for (const card of finalCards) {
1433
+ const prov = card.provenance[0];
1434
+ const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1435
+ const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1436
+ if (!groupId) continue;
1437
+ if (!surfacedByGroup.has(groupId)) {
1438
+ surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
1439
+ }
1440
+ surfacedByGroup.get(groupId)[mode].push(card.cardId);
1441
+ }
1442
+ for (const group of this.config.groups) {
1443
+ const groupState = nextState.groups[group.id];
1444
+ const surfaced = surfacedByGroup.get(group.id);
1445
+ if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
1446
+ groupState.lastSurfacedAt = isoNow();
1447
+ groupState.sessionsSinceSurfaced = 0;
1448
+ if (surfaced.supportIds.length > 0) {
1449
+ groupState.lastSupportAt = isoNow();
1334
1450
  }
1335
- ]
1336
- }));
1451
+ }
1452
+ }
1453
+ await this.putStrategyState(nextState).catch((e) => {
1454
+ logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
1455
+ });
1337
1456
  logger.info(
1338
- `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1457
+ `[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)`
1339
1458
  );
1459
+ return finalCards;
1460
+ }
1461
+ parseConfig(serializedData) {
1462
+ try {
1463
+ const parsed = JSON.parse(serializedData);
1464
+ const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
1465
+ const groups = groupsRaw.map((raw, i) => ({
1466
+ id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
1467
+ targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
1468
+ supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
1469
+ supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
1470
+ freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
1471
+ maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
1472
+ maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
1473
+ hierarchyWalk: {
1474
+ enabled: raw.hierarchyWalk?.enabled !== false,
1475
+ maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1476
+ },
1477
+ retireOnEncounter: raw.retireOnEncounter !== false
1478
+ })).filter((g) => g.targetCardIds.length > 0);
1479
+ return { groups };
1480
+ } catch {
1481
+ return { groups: [] };
1482
+ }
1483
+ }
1484
+ async loadHierarchyConfigs() {
1485
+ try {
1486
+ const strategies = await this.course.getNavigationStrategies();
1487
+ return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
1488
+ try {
1489
+ const parsed = JSON.parse(s.serializedData);
1490
+ return {
1491
+ prerequisites: parsed.prerequisites || {}
1492
+ };
1493
+ } catch {
1494
+ return { prerequisites: {} };
1495
+ }
1496
+ });
1497
+ } catch (e) {
1498
+ logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
1499
+ return [];
1500
+ }
1501
+ }
1502
+ buildGroupRuntimeState(args) {
1503
+ const {
1504
+ group,
1505
+ priorState,
1506
+ activeIds,
1507
+ seenIds,
1508
+ tagsByCard,
1509
+ hierarchyConfigs,
1510
+ userTagElo,
1511
+ userGlobalElo
1512
+ } = args;
1513
+ const encounteredTargets = /* @__PURE__ */ new Set();
1514
+ for (const cardId of group.targetCardIds) {
1515
+ if (activeIds.has(cardId) || seenIds.has(cardId)) {
1516
+ encounteredTargets.add(cardId);
1517
+ }
1518
+ }
1519
+ if (priorState?.encounteredCardIds?.length) {
1520
+ for (const cardId of priorState.encounteredCardIds) {
1521
+ encounteredTargets.add(cardId);
1522
+ }
1523
+ }
1524
+ const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
1525
+ const targetTags = /* @__PURE__ */ new Map();
1526
+ for (const cardId of pendingTargets) {
1527
+ targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
1528
+ }
1529
+ const blockedTargets = [];
1530
+ const surfaceableTargets = [];
1531
+ const supportTags = /* @__PURE__ */ new Set();
1532
+ for (const cardId of pendingTargets) {
1533
+ const tags = targetTags.get(cardId) ?? [];
1534
+ const resolution = this.resolveBlockedSupportTags(
1535
+ tags,
1536
+ hierarchyConfigs,
1537
+ userTagElo,
1538
+ userGlobalElo,
1539
+ group.hierarchyWalk?.enabled !== false,
1540
+ group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
1541
+ );
1542
+ if (resolution.blocked) {
1543
+ blockedTargets.push(cardId);
1544
+ resolution.supportTags.forEach((t) => supportTags.add(t));
1545
+ } else {
1546
+ surfaceableTargets.push(cardId);
1547
+ }
1548
+ }
1549
+ const supportCandidates = dedupe([
1550
+ ...group.supportCardIds ?? [],
1551
+ ...this.findSupportCardsByTags(
1552
+ group,
1553
+ tagsByCard,
1554
+ [...supportTags]
1555
+ )
1556
+ ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
1557
+ const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
1558
+ const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
1559
+ const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
1560
+ const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
1561
+ const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
1562
+ return {
1563
+ group,
1564
+ encounteredTargets,
1565
+ pendingTargets,
1566
+ blockedTargets,
1567
+ surfaceableTargets,
1568
+ targetTags,
1569
+ supportCandidates,
1570
+ supportTags: [...supportTags],
1571
+ pressureMultiplier,
1572
+ supportMultiplier
1573
+ };
1574
+ }
1575
+ buildNextGroupState(runtime, prior) {
1576
+ const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
1577
+ const surfacedThisRun = false;
1578
+ return {
1579
+ encounteredCardIds: [...runtime.encounteredTargets].sort(),
1580
+ lastSurfacedAt: prior?.lastSurfacedAt ?? null,
1581
+ sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
1582
+ lastSupportAt: prior?.lastSupportAt ?? null,
1583
+ blockedTargetIds: [...runtime.blockedTargets].sort(),
1584
+ lastResolvedSupportTags: [...runtime.supportTags].sort()
1585
+ };
1586
+ }
1587
+ buildDirectTargetCards(runtime, courseId, emittedIds) {
1588
+ const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
1589
+ const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
1590
+ const cards = [];
1591
+ for (const cardId of directIds) {
1592
+ emittedIds.add(cardId);
1593
+ cards.push({
1594
+ cardId,
1595
+ courseId,
1596
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1597
+ provenance: [
1598
+ {
1599
+ strategy: "prescribed",
1600
+ strategyName: this.strategyName || this.name,
1601
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1602
+ action: "generated",
1603
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1604
+ reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
1605
+ }
1606
+ ]
1607
+ });
1608
+ }
1609
+ return cards;
1610
+ }
1611
+ buildSupportCards(runtime, courseId, emittedIds) {
1612
+ if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
1613
+ return [];
1614
+ }
1615
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
1616
+ const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
1617
+ const cards = [];
1618
+ for (const cardId of supportIds) {
1619
+ emittedIds.add(cardId);
1620
+ cards.push({
1621
+ cardId,
1622
+ courseId,
1623
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1624
+ provenance: [
1625
+ {
1626
+ strategy: "prescribed",
1627
+ strategyName: this.strategyName || this.name,
1628
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1629
+ action: "generated",
1630
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1631
+ reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
1632
+ }
1633
+ ]
1634
+ });
1635
+ }
1340
1636
  return cards;
1341
1637
  }
1638
+ findSupportCardsByTags(group, tagsByCard, supportTags) {
1639
+ if (supportTags.length === 0) {
1640
+ return [];
1641
+ }
1642
+ const explicitSupportIds = group.supportCardIds ?? [];
1643
+ const explicitPatterns = group.supportTagPatterns ?? [];
1644
+ if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
1645
+ return [];
1646
+ }
1647
+ const candidates = /* @__PURE__ */ new Set();
1648
+ for (const cardId of explicitSupportIds) {
1649
+ const cardTags = tagsByCard.get(cardId) ?? [];
1650
+ const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
1651
+ const matchesPattern = explicitPatterns.some(
1652
+ (pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
1653
+ );
1654
+ if (matchesResolved || matchesPattern) {
1655
+ candidates.add(cardId);
1656
+ }
1657
+ }
1658
+ return [...candidates];
1659
+ }
1660
+ resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
1661
+ if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
1662
+ return {
1663
+ blocked: false,
1664
+ supportTags: []
1665
+ };
1666
+ }
1667
+ const supportTags = /* @__PURE__ */ new Set();
1668
+ let blocked = false;
1669
+ for (const targetTag of targetTags) {
1670
+ for (const hierarchy of hierarchyConfigs) {
1671
+ const prereqs = hierarchy.prerequisites[targetTag];
1672
+ if (!prereqs || prereqs.length === 0) continue;
1673
+ const unmet = prereqs.filter(
1674
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1675
+ );
1676
+ if (unmet.length === 0) {
1677
+ continue;
1678
+ }
1679
+ blocked = true;
1680
+ for (const prereq of unmet) {
1681
+ this.collectSupportTagsRecursive(
1682
+ prereq.tag,
1683
+ hierarchyConfigs,
1684
+ userTagElo,
1685
+ userGlobalElo,
1686
+ maxDepth,
1687
+ /* @__PURE__ */ new Set(),
1688
+ supportTags
1689
+ );
1690
+ }
1691
+ }
1692
+ }
1693
+ return { blocked, supportTags: [...supportTags] };
1694
+ }
1695
+ collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
1696
+ if (depth < 0 || visited.has(tag)) return;
1697
+ if (this.isHardGatedTag(tag)) return;
1698
+ visited.add(tag);
1699
+ let walkedFurther = false;
1700
+ for (const hierarchy of hierarchyConfigs) {
1701
+ const prereqs = hierarchy.prerequisites[tag];
1702
+ if (!prereqs || prereqs.length === 0) continue;
1703
+ const unmet = prereqs.filter(
1704
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1705
+ );
1706
+ if (unmet.length > 0 && depth > 0) {
1707
+ walkedFurther = true;
1708
+ for (const prereq of unmet) {
1709
+ this.collectSupportTagsRecursive(
1710
+ prereq.tag,
1711
+ hierarchyConfigs,
1712
+ userTagElo,
1713
+ userGlobalElo,
1714
+ depth - 1,
1715
+ visited,
1716
+ out
1717
+ );
1718
+ }
1719
+ }
1720
+ }
1721
+ if (!walkedFurther) {
1722
+ out.add(tag);
1723
+ }
1724
+ }
1725
+ isHardGatedTag(tag) {
1726
+ return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
1727
+ }
1728
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1729
+ if (!userTagElo) return false;
1730
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1731
+ if (userTagElo.count < minCount) return false;
1732
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1733
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1734
+ }
1735
+ if (prereq.masteryThreshold?.minCount !== void 0) {
1736
+ return true;
1737
+ }
1738
+ return userTagElo.score >= userGlobalElo;
1739
+ }
1342
1740
  };
1343
1741
  }
1344
1742
  });
@@ -1701,14 +2099,14 @@ var hierarchyDefinition_exports = {};
1701
2099
  __export(hierarchyDefinition_exports, {
1702
2100
  default: () => HierarchyDefinitionNavigator
1703
2101
  });
1704
- var import_common6, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
2102
+ var import_common6, DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
1705
2103
  var init_hierarchyDefinition = __esm({
1706
2104
  "src/core/navigators/filters/hierarchyDefinition.ts"() {
1707
2105
  "use strict";
1708
2106
  init_navigators();
1709
2107
  import_common6 = require("@vue-skuilder/common");
1710
2108
  init_logger();
1711
- DEFAULT_MIN_COUNT = 3;
2109
+ DEFAULT_MIN_COUNT2 = 3;
1712
2110
  HierarchyDefinitionNavigator = class extends ContentNavigator {
1713
2111
  config;
1714
2112
  /** Human-readable name for CardFilter interface */
@@ -1735,7 +2133,7 @@ var init_hierarchyDefinition = __esm({
1735
2133
  */
1736
2134
  isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1737
2135
  if (!userTagElo) return false;
1738
- const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
2136
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1739
2137
  if (userTagElo.count < minCount) return false;
1740
2138
  if (prereq.masteryThreshold?.minElo !== void 0) {
1741
2139
  return userTagElo.score >= prereq.masteryThreshold.minElo;
@@ -1836,18 +2234,55 @@ var init_hierarchyDefinition = __esm({
1836
2234
  }
1837
2235
  return boosts;
1838
2236
  }
2237
+ /**
2238
+ * Build a map of gated tag → max configured targetBoost for all *open* gates.
2239
+ *
2240
+ * When a gate opens (prereqs met), cards carrying the gated tag get boosted —
2241
+ * ensuring newly-unlocked content surfaces promptly. The boost is a static
2242
+ * multiplier; natural ELO/SRS deprioritization after interaction handles decay.
2243
+ */
2244
+ getTargetBoosts(unlockedTags) {
2245
+ const boosts = /* @__PURE__ */ new Map();
2246
+ const configKeys = Object.keys(this.config.prerequisites);
2247
+ const unlockedArr = [...unlockedTags];
2248
+ logger.info(
2249
+ `[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
2250
+ );
2251
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2252
+ if (!unlockedTags.has(tagId)) continue;
2253
+ logger.info(
2254
+ `[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
2255
+ );
2256
+ for (const prereq of prereqs) {
2257
+ if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
2258
+ const existing = boosts.get(tagId) ?? 1;
2259
+ boosts.set(tagId, Math.max(existing, prereq.targetBoost));
2260
+ }
2261
+ }
2262
+ if (boosts.size > 0) {
2263
+ logger.info(
2264
+ `[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
2265
+ );
2266
+ } else {
2267
+ logger.info(
2268
+ `[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
2269
+ );
2270
+ }
2271
+ return boosts;
2272
+ }
1839
2273
  /**
1840
2274
  * CardFilter.transform implementation.
1841
2275
  *
1842
- * Two effects:
1843
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1844
- * 2. Cards carrying prereq tags of closed gates receive a configured
1845
- * boost (preReqBoost), steering toward content that unlocks gates
2276
+ * Three effects:
2277
+ * 1. Cards with locked tags receive score * 0.02 (gating penalty)
2278
+ * 2. Cards carrying prereq tags of closed gates receive preReqBoost
2279
+ * 3. Cards carrying gated tags of open gates receive targetBoost
1846
2280
  */
1847
2281
  async transform(cards, context) {
1848
2282
  const masteredTags = await this.getMasteredTags(context);
1849
2283
  const unlockedTags = this.getUnlockedTags(masteredTags);
1850
2284
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
2285
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
1851
2286
  const gated = [];
1852
2287
  for (const card of cards) {
1853
2288
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1880,6 +2315,26 @@ var init_hierarchyDefinition = __esm({
1880
2315
  );
1881
2316
  }
1882
2317
  }
2318
+ if (isUnlocked && targetBoosts.size > 0) {
2319
+ const cardTags = card.tags ?? [];
2320
+ let maxTargetBoost = 1;
2321
+ const boostedTargets = [];
2322
+ for (const tag of cardTags) {
2323
+ const boost = targetBoosts.get(tag);
2324
+ if (boost && boost > maxTargetBoost) {
2325
+ maxTargetBoost = boost;
2326
+ boostedTargets.push(tag);
2327
+ }
2328
+ }
2329
+ if (maxTargetBoost > 1) {
2330
+ finalScore *= maxTargetBoost;
2331
+ action = "boosted";
2332
+ finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
2333
+ logger.info(
2334
+ `[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2335
+ );
2336
+ }
2337
+ }
1883
2338
  gated.push({
1884
2339
  ...card,
1885
2340
  score: finalScore,
@@ -2064,13 +2519,13 @@ var interferenceMitigator_exports = {};
2064
2519
  __export(interferenceMitigator_exports, {
2065
2520
  default: () => InterferenceMitigatorNavigator
2066
2521
  });
2067
- var import_common7, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2522
+ var import_common7, DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2068
2523
  var init_interferenceMitigator = __esm({
2069
2524
  "src/core/navigators/filters/interferenceMitigator.ts"() {
2070
2525
  "use strict";
2071
2526
  init_navigators();
2072
2527
  import_common7 = require("@vue-skuilder/common");
2073
- DEFAULT_MIN_COUNT2 = 10;
2528
+ DEFAULT_MIN_COUNT3 = 10;
2074
2529
  DEFAULT_MIN_ELAPSED_DAYS = 3;
2075
2530
  DEFAULT_INTERFERENCE_DECAY = 0.8;
2076
2531
  InterferenceMitigatorNavigator = class extends ContentNavigator {
@@ -2095,7 +2550,7 @@ var init_interferenceMitigator = __esm({
2095
2550
  return {
2096
2551
  interferenceSets: sets,
2097
2552
  maturityThreshold: {
2098
- minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2553
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
2099
2554
  minElo: parsed.maturityThreshold?.minElo,
2100
2555
  minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2101
2556
  },
@@ -2105,7 +2560,7 @@ var init_interferenceMitigator = __esm({
2105
2560
  return {
2106
2561
  interferenceSets: [],
2107
2562
  maturityThreshold: {
2108
- minCount: DEFAULT_MIN_COUNT2,
2563
+ minCount: DEFAULT_MIN_COUNT3,
2109
2564
  minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2110
2565
  },
2111
2566
  defaultDecay: DEFAULT_INTERFERENCE_DECAY
@@ -2152,7 +2607,7 @@ var init_interferenceMitigator = __esm({
2152
2607
  try {
2153
2608
  const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2154
2609
  const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
2155
- const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2610
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
2156
2611
  const minElo = this.config.maturityThreshold?.minElo;
2157
2612
  const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2158
2613
  const minCountForElapsed = minElapsedDays * 2;
@@ -2481,96 +2936,1683 @@ var init_learning = __esm({
2481
2936
  }
2482
2937
  });
2483
2938
 
2484
- // src/core/orchestration/signal.ts
2485
- var init_signal = __esm({
2486
- "src/core/orchestration/signal.ts"() {
2487
- "use strict";
2488
- }
2489
- });
2939
+ // src/core/orchestration/signal.ts
2940
+ var init_signal = __esm({
2941
+ "src/core/orchestration/signal.ts"() {
2942
+ "use strict";
2943
+ }
2944
+ });
2945
+
2946
+ // src/core/orchestration/recording.ts
2947
+ var init_recording = __esm({
2948
+ "src/core/orchestration/recording.ts"() {
2949
+ "use strict";
2950
+ init_signal();
2951
+ init_types_legacy();
2952
+ init_logger();
2953
+ }
2954
+ });
2955
+
2956
+ // src/core/orchestration/index.ts
2957
+ function fnv1a(str) {
2958
+ let hash = 2166136261;
2959
+ for (let i = 0; i < str.length; i++) {
2960
+ hash ^= str.charCodeAt(i);
2961
+ hash = Math.imul(hash, 16777619);
2962
+ }
2963
+ return hash >>> 0;
2964
+ }
2965
+ function computeDeviation(userId, strategyId, salt) {
2966
+ const input = `${userId}:${strategyId}:${salt}`;
2967
+ const hash = fnv1a(input);
2968
+ const normalized = hash / 4294967296;
2969
+ return normalized * 2 - 1;
2970
+ }
2971
+ function computeSpread(confidence) {
2972
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
2973
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2974
+ }
2975
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2976
+ const deviation = computeDeviation(userId, strategyId, salt);
2977
+ const spread = computeSpread(learnable.confidence);
2978
+ const adjustment = deviation * spread * learnable.weight;
2979
+ const effective = learnable.weight + adjustment;
2980
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2981
+ }
2982
+ async function createOrchestrationContext(user, course) {
2983
+ let courseConfig;
2984
+ try {
2985
+ courseConfig = await course.getCourseConfig();
2986
+ } catch (e) {
2987
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
2988
+ courseConfig = {
2989
+ name: "Unknown",
2990
+ description: "",
2991
+ public: false,
2992
+ deleted: false,
2993
+ creator: "",
2994
+ admins: [],
2995
+ moderators: [],
2996
+ dataShapes: [],
2997
+ questionTypes: [],
2998
+ orchestration: { salt: "default" }
2999
+ };
3000
+ }
3001
+ const userId = user.getUsername();
3002
+ const salt = courseConfig.orchestration?.salt || "default_salt";
3003
+ return {
3004
+ user,
3005
+ course,
3006
+ userId,
3007
+ courseConfig,
3008
+ getEffectiveWeight(strategyId, learnable) {
3009
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
3010
+ },
3011
+ getDeviation(strategyId) {
3012
+ return computeDeviation(userId, strategyId, salt);
3013
+ }
3014
+ };
3015
+ }
3016
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
3017
+ var init_orchestration = __esm({
3018
+ "src/core/orchestration/index.ts"() {
3019
+ "use strict";
3020
+ init_logger();
3021
+ init_gradient();
3022
+ init_learning();
3023
+ init_signal();
3024
+ init_recording();
3025
+ MIN_SPREAD = 0.1;
3026
+ MAX_SPREAD = 0.5;
3027
+ MIN_WEIGHT = 0.1;
3028
+ MAX_WEIGHT = 3;
3029
+ }
3030
+ });
3031
+
3032
+ // src/core/util/index.ts
3033
+ function getCardHistoryID(courseID, cardID) {
3034
+ return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
3035
+ }
3036
+ var init_util = __esm({
3037
+ "src/core/util/index.ts"() {
3038
+ "use strict";
3039
+ init_types_legacy();
3040
+ }
3041
+ });
3042
+
3043
+ // src/study/SpacedRepetition.ts
3044
+ var import_moment2, import_common8, duration;
3045
+ var init_SpacedRepetition = __esm({
3046
+ "src/study/SpacedRepetition.ts"() {
3047
+ "use strict";
3048
+ init_util();
3049
+ import_moment2 = __toESM(require("moment"), 1);
3050
+ import_common8 = require("@vue-skuilder/common");
3051
+ init_logger();
3052
+ duration = import_moment2.default.duration;
3053
+ }
3054
+ });
3055
+
3056
+ // src/study/services/SrsService.ts
3057
+ var import_moment3;
3058
+ var init_SrsService = __esm({
3059
+ "src/study/services/SrsService.ts"() {
3060
+ "use strict";
3061
+ import_moment3 = __toESM(require("moment"), 1);
3062
+ init_couch();
3063
+ init_SpacedRepetition();
3064
+ init_logger();
3065
+ }
3066
+ });
3067
+
3068
+ // src/study/services/EloService.ts
3069
+ var import_common9;
3070
+ var init_EloService = __esm({
3071
+ "src/study/services/EloService.ts"() {
3072
+ "use strict";
3073
+ import_common9 = require("@vue-skuilder/common");
3074
+ init_logger();
3075
+ }
3076
+ });
3077
+
3078
+ // src/study/services/ResponseProcessor.ts
3079
+ var import_common10;
3080
+ var init_ResponseProcessor = __esm({
3081
+ "src/study/services/ResponseProcessor.ts"() {
3082
+ "use strict";
3083
+ init_core();
3084
+ init_logger();
3085
+ import_common10 = require("@vue-skuilder/common");
3086
+ }
3087
+ });
3088
+
3089
+ // src/study/services/CardHydrationService.ts
3090
+ var import_common11;
3091
+ var init_CardHydrationService = __esm({
3092
+ "src/study/services/CardHydrationService.ts"() {
3093
+ "use strict";
3094
+ import_common11 = require("@vue-skuilder/common");
3095
+ init_logger();
3096
+ }
3097
+ });
3098
+
3099
+ // src/study/ItemQueue.ts
3100
+ var init_ItemQueue = __esm({
3101
+ "src/study/ItemQueue.ts"() {
3102
+ "use strict";
3103
+ }
3104
+ });
3105
+
3106
+ // src/util/packer/types.ts
3107
+ var init_types3 = __esm({
3108
+ "src/util/packer/types.ts"() {
3109
+ "use strict";
3110
+ }
3111
+ });
3112
+
3113
+ // src/util/packer/CouchDBToStaticPacker.ts
3114
+ var init_CouchDBToStaticPacker = __esm({
3115
+ "src/util/packer/CouchDBToStaticPacker.ts"() {
3116
+ "use strict";
3117
+ init_types_legacy();
3118
+ init_logger();
3119
+ }
3120
+ });
3121
+
3122
+ // src/util/packer/index.ts
3123
+ var init_packer = __esm({
3124
+ "src/util/packer/index.ts"() {
3125
+ "use strict";
3126
+ init_types3();
3127
+ init_CouchDBToStaticPacker();
3128
+ }
3129
+ });
3130
+
3131
+ // src/util/migrator/types.ts
3132
+ var DEFAULT_MIGRATION_OPTIONS;
3133
+ var init_types4 = __esm({
3134
+ "src/util/migrator/types.ts"() {
3135
+ "use strict";
3136
+ DEFAULT_MIGRATION_OPTIONS = {
3137
+ chunkBatchSize: 100,
3138
+ validateRoundTrip: false,
3139
+ cleanupOnFailure: true,
3140
+ timeout: 3e5
3141
+ // 5 minutes
3142
+ };
3143
+ }
3144
+ });
3145
+
3146
+ // src/util/migrator/FileSystemAdapter.ts
3147
+ var FileSystemError;
3148
+ var init_FileSystemAdapter = __esm({
3149
+ "src/util/migrator/FileSystemAdapter.ts"() {
3150
+ "use strict";
3151
+ FileSystemError = class extends Error {
3152
+ constructor(message, operation, filePath, cause) {
3153
+ super(message);
3154
+ this.operation = operation;
3155
+ this.filePath = filePath;
3156
+ this.cause = cause;
3157
+ this.name = "FileSystemError";
3158
+ }
3159
+ };
3160
+ }
3161
+ });
3162
+
3163
+ // src/util/migrator/validation.ts
3164
+ async function validateStaticCourse(staticPath, fs) {
3165
+ const validation = {
3166
+ valid: true,
3167
+ manifestExists: false,
3168
+ chunksExist: false,
3169
+ attachmentsExist: false,
3170
+ errors: [],
3171
+ warnings: []
3172
+ };
3173
+ try {
3174
+ if (fs) {
3175
+ const stats = await fs.stat(staticPath);
3176
+ if (!stats.isDirectory()) {
3177
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3178
+ validation.valid = false;
3179
+ return validation;
3180
+ }
3181
+ } else if (!nodeFS) {
3182
+ validation.errors.push("File system access not available - validation skipped");
3183
+ validation.valid = false;
3184
+ return validation;
3185
+ } else {
3186
+ const stats = await nodeFS.promises.stat(staticPath);
3187
+ if (!stats.isDirectory()) {
3188
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3189
+ validation.valid = false;
3190
+ return validation;
3191
+ }
3192
+ }
3193
+ let manifestPath = `${staticPath}/manifest.json`;
3194
+ try {
3195
+ if (fs) {
3196
+ manifestPath = fs.joinPath(staticPath, "manifest.json");
3197
+ if (await fs.exists(manifestPath)) {
3198
+ validation.manifestExists = true;
3199
+ const manifestContent = await fs.readFile(manifestPath);
3200
+ const manifest = JSON.parse(manifestContent);
3201
+ validation.courseId = manifest.courseId;
3202
+ validation.courseName = manifest.courseName;
3203
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3204
+ validation.errors.push("Invalid manifest structure");
3205
+ validation.valid = false;
3206
+ }
3207
+ } else {
3208
+ validation.errors.push(`Manifest not found: ${manifestPath}`);
3209
+ validation.valid = false;
3210
+ }
3211
+ } else {
3212
+ manifestPath = `${staticPath}/manifest.json`;
3213
+ await nodeFS.promises.access(manifestPath);
3214
+ validation.manifestExists = true;
3215
+ const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
3216
+ const manifest = JSON.parse(manifestContent);
3217
+ validation.courseId = manifest.courseId;
3218
+ validation.courseName = manifest.courseName;
3219
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3220
+ validation.errors.push("Invalid manifest structure");
3221
+ validation.valid = false;
3222
+ }
3223
+ }
3224
+ } catch (error) {
3225
+ const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
3226
+ validation.errors.push(errorMessage);
3227
+ validation.valid = false;
3228
+ }
3229
+ let chunksPath = `${staticPath}/chunks`;
3230
+ try {
3231
+ if (fs) {
3232
+ chunksPath = fs.joinPath(staticPath, "chunks");
3233
+ if (await fs.exists(chunksPath)) {
3234
+ const chunksStats = await fs.stat(chunksPath);
3235
+ if (chunksStats.isDirectory()) {
3236
+ validation.chunksExist = true;
3237
+ } else {
3238
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3239
+ validation.valid = false;
3240
+ }
3241
+ } else {
3242
+ validation.errors.push(`Chunks directory not found: ${chunksPath}`);
3243
+ validation.valid = false;
3244
+ }
3245
+ } else {
3246
+ chunksPath = `${staticPath}/chunks`;
3247
+ const chunksStats = await nodeFS.promises.stat(chunksPath);
3248
+ if (chunksStats.isDirectory()) {
3249
+ validation.chunksExist = true;
3250
+ } else {
3251
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3252
+ validation.valid = false;
3253
+ }
3254
+ }
3255
+ } catch (error) {
3256
+ const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
3257
+ validation.errors.push(errorMessage);
3258
+ validation.valid = false;
3259
+ }
3260
+ let attachmentsPath;
3261
+ try {
3262
+ if (fs) {
3263
+ attachmentsPath = fs.joinPath(staticPath, "attachments");
3264
+ if (await fs.exists(attachmentsPath)) {
3265
+ const attachmentsStats = await fs.stat(attachmentsPath);
3266
+ if (attachmentsStats.isDirectory()) {
3267
+ validation.attachmentsExist = true;
3268
+ }
3269
+ } else {
3270
+ validation.warnings.push(
3271
+ `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
3272
+ );
3273
+ }
3274
+ } else {
3275
+ attachmentsPath = `${staticPath}/attachments`;
3276
+ const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
3277
+ if (attachmentsStats.isDirectory()) {
3278
+ validation.attachmentsExist = true;
3279
+ }
3280
+ }
3281
+ } catch (error) {
3282
+ attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
3283
+ const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
3284
+ validation.warnings.push(warningMessage);
3285
+ }
3286
+ } catch (error) {
3287
+ validation.errors.push(
3288
+ `Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
3289
+ );
3290
+ validation.valid = false;
3291
+ }
3292
+ return validation;
3293
+ }
3294
+ async function validateMigration(targetDB, expectedCounts, manifest) {
3295
+ const validation = {
3296
+ valid: true,
3297
+ documentCountMatch: false,
3298
+ attachmentIntegrity: false,
3299
+ viewFunctionality: false,
3300
+ issues: []
3301
+ };
3302
+ try {
3303
+ logger.info("Starting migration validation...");
3304
+ const actualCounts = await getActualDocumentCounts(targetDB);
3305
+ validation.documentCountMatch = compareDocumentCounts(
3306
+ expectedCounts,
3307
+ actualCounts,
3308
+ validation.issues
3309
+ );
3310
+ await validateCourseConfig(targetDB, manifest, validation.issues);
3311
+ validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
3312
+ validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
3313
+ validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
3314
+ logger.info(`Migration validation completed. Valid: ${validation.valid}`);
3315
+ if (validation.issues.length > 0) {
3316
+ logger.info(`Validation issues: ${validation.issues.length}`);
3317
+ validation.issues.forEach((issue) => {
3318
+ if (issue.type === "error") {
3319
+ logger.error(`${issue.category}: ${issue.message}`);
3320
+ } else {
3321
+ logger.warn(`${issue.category}: ${issue.message}`);
3322
+ }
3323
+ });
3324
+ }
3325
+ } catch (error) {
3326
+ validation.valid = false;
3327
+ validation.issues.push({
3328
+ type: "error",
3329
+ category: "metadata",
3330
+ message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
3331
+ });
3332
+ }
3333
+ return validation;
3334
+ }
3335
+ async function getActualDocumentCounts(db) {
3336
+ const counts = {};
3337
+ try {
3338
+ const allDocs = await db.allDocs({ include_docs: true });
3339
+ for (const row of allDocs.rows) {
3340
+ if (row.id.startsWith("_design/")) {
3341
+ counts["_design"] = (counts["_design"] || 0) + 1;
3342
+ continue;
3343
+ }
3344
+ const doc = row.doc;
3345
+ if (doc && doc.docType) {
3346
+ counts[doc.docType] = (counts[doc.docType] || 0) + 1;
3347
+ } else {
3348
+ counts["unknown"] = (counts["unknown"] || 0) + 1;
3349
+ }
3350
+ }
3351
+ } catch (error) {
3352
+ logger.error("Failed to get actual document counts:", error);
3353
+ }
3354
+ return counts;
3355
+ }
3356
+ function compareDocumentCounts(expected, actual, issues) {
3357
+ let countsMatch = true;
3358
+ for (const [docType, expectedCount] of Object.entries(expected)) {
3359
+ const actualCount = actual[docType] || 0;
3360
+ if (actualCount !== expectedCount) {
3361
+ countsMatch = false;
3362
+ issues.push({
3363
+ type: "error",
3364
+ category: "documents",
3365
+ message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
3366
+ });
3367
+ }
3368
+ }
3369
+ for (const [docType, actualCount] of Object.entries(actual)) {
3370
+ if (!expected[docType] && docType !== "_design") {
3371
+ issues.push({
3372
+ type: "warning",
3373
+ category: "documents",
3374
+ message: `Unexpected document type found: ${docType} (${actualCount} documents)`
3375
+ });
3376
+ }
3377
+ }
3378
+ return countsMatch;
3379
+ }
3380
+ async function validateCourseConfig(db, manifest, issues) {
3381
+ try {
3382
+ const courseConfig = await db.get("CourseConfig");
3383
+ if (!courseConfig) {
3384
+ issues.push({
3385
+ type: "error",
3386
+ category: "course_config",
3387
+ message: "CourseConfig document not found after migration"
3388
+ });
3389
+ return;
3390
+ }
3391
+ if (!courseConfig.courseID) {
3392
+ issues.push({
3393
+ type: "warning",
3394
+ category: "course_config",
3395
+ message: "CourseConfig document missing courseID field"
3396
+ });
3397
+ }
3398
+ if (courseConfig.courseID !== manifest.courseId) {
3399
+ issues.push({
3400
+ type: "warning",
3401
+ category: "course_config",
3402
+ message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
3403
+ });
3404
+ }
3405
+ logger.debug("CourseConfig document validation passed");
3406
+ } catch (error) {
3407
+ if (error.status === 404) {
3408
+ issues.push({
3409
+ type: "error",
3410
+ category: "course_config",
3411
+ message: "CourseConfig document not found in database"
3412
+ });
3413
+ } else {
3414
+ issues.push({
3415
+ type: "error",
3416
+ category: "course_config",
3417
+ message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
3418
+ });
3419
+ }
3420
+ }
3421
+ }
3422
+ async function validateViews(db, manifest, issues) {
3423
+ let viewsValid = true;
3424
+ try {
3425
+ for (const designDoc of manifest.designDocs) {
3426
+ try {
3427
+ const doc = await db.get(designDoc._id);
3428
+ if (!doc) {
3429
+ viewsValid = false;
3430
+ issues.push({
3431
+ type: "error",
3432
+ category: "views",
3433
+ message: `Design document not found: ${designDoc._id}`
3434
+ });
3435
+ continue;
3436
+ }
3437
+ for (const viewName of Object.keys(designDoc.views)) {
3438
+ try {
3439
+ const viewPath = `${designDoc._id}/${viewName}`;
3440
+ await db.query(viewPath, { limit: 1 });
3441
+ } catch (viewError) {
3442
+ viewsValid = false;
3443
+ issues.push({
3444
+ type: "error",
3445
+ category: "views",
3446
+ message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
3447
+ });
3448
+ }
3449
+ }
3450
+ } catch (error) {
3451
+ viewsValid = false;
3452
+ issues.push({
3453
+ type: "error",
3454
+ category: "views",
3455
+ message: `Failed to validate design document ${designDoc._id}: ${error}`
3456
+ });
3457
+ }
3458
+ }
3459
+ } catch (error) {
3460
+ viewsValid = false;
3461
+ issues.push({
3462
+ type: "error",
3463
+ category: "views",
3464
+ message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
3465
+ });
3466
+ }
3467
+ return viewsValid;
3468
+ }
3469
+ async function validateAttachmentIntegrity(db, issues) {
3470
+ let attachmentsValid = true;
3471
+ try {
3472
+ const allDocs = await db.allDocs({
3473
+ include_docs: true,
3474
+ limit: 10
3475
+ // Sample first 10 documents for performance
3476
+ });
3477
+ let attachmentCount = 0;
3478
+ let validAttachments = 0;
3479
+ for (const row of allDocs.rows) {
3480
+ const doc = row.doc;
3481
+ if (doc && doc._attachments) {
3482
+ for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
3483
+ attachmentCount++;
3484
+ try {
3485
+ const attachment = await db.getAttachment(doc._id, attachmentName);
3486
+ if (attachment) {
3487
+ validAttachments++;
3488
+ }
3489
+ } catch (attachmentError) {
3490
+ attachmentsValid = false;
3491
+ issues.push({
3492
+ type: "error",
3493
+ category: "attachments",
3494
+ message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
3495
+ });
3496
+ }
3497
+ }
3498
+ }
3499
+ }
3500
+ if (attachmentCount === 0) {
3501
+ issues.push({
3502
+ type: "warning",
3503
+ category: "attachments",
3504
+ message: "No attachments found in sampled documents"
3505
+ });
3506
+ } else {
3507
+ logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
3508
+ }
3509
+ } catch (error) {
3510
+ attachmentsValid = false;
3511
+ issues.push({
3512
+ type: "error",
3513
+ category: "attachments",
3514
+ message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
3515
+ });
3516
+ }
3517
+ return attachmentsValid;
3518
+ }
3519
+ var nodeFS;
3520
+ var init_validation = __esm({
3521
+ "src/util/migrator/validation.ts"() {
3522
+ "use strict";
3523
+ init_logger();
3524
+ init_FileSystemAdapter();
3525
+ nodeFS = null;
3526
+ try {
3527
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3528
+ nodeFS = eval("require")("fs");
3529
+ nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
3530
+ }
3531
+ } catch {
3532
+ }
3533
+ }
3534
+ });
3535
+
3536
+ // src/util/migrator/StaticToCouchDBMigrator.ts
3537
+ var nodeFS2, nodePath, StaticToCouchDBMigrator;
3538
+ var init_StaticToCouchDBMigrator = __esm({
3539
+ "src/util/migrator/StaticToCouchDBMigrator.ts"() {
3540
+ "use strict";
3541
+ init_logger();
3542
+ init_types4();
3543
+ init_validation();
3544
+ init_FileSystemAdapter();
3545
+ nodeFS2 = null;
3546
+ nodePath = null;
3547
+ try {
3548
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3549
+ nodeFS2 = eval("require")("fs");
3550
+ nodePath = eval("require")("path");
3551
+ nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
3552
+ }
3553
+ } catch {
3554
+ }
3555
+ StaticToCouchDBMigrator = class {
3556
+ options;
3557
+ progressCallback;
3558
+ fs;
3559
+ constructor(options = {}, fileSystemAdapter) {
3560
+ this.options = {
3561
+ ...DEFAULT_MIGRATION_OPTIONS,
3562
+ ...options
3563
+ };
3564
+ this.fs = fileSystemAdapter;
3565
+ }
3566
+ /**
3567
+ * Set a progress callback to receive updates during migration
3568
+ */
3569
+ setProgressCallback(callback) {
3570
+ this.progressCallback = callback;
3571
+ }
3572
+ /**
3573
+ * Migrate a static course to CouchDB
3574
+ */
3575
+ async migrateCourse(staticPath, targetDB) {
3576
+ const startTime = Date.now();
3577
+ const result = {
3578
+ success: false,
3579
+ documentsRestored: 0,
3580
+ attachmentsRestored: 0,
3581
+ designDocsRestored: 0,
3582
+ courseConfigRestored: 0,
3583
+ errors: [],
3584
+ warnings: [],
3585
+ migrationTime: 0
3586
+ };
3587
+ try {
3588
+ logger.info(`Starting migration from ${staticPath} to CouchDB`);
3589
+ this.reportProgress("manifest", 0, 1, "Validating static course...");
3590
+ const validation = await validateStaticCourse(staticPath, this.fs);
3591
+ if (!validation.valid) {
3592
+ result.errors.push(...validation.errors);
3593
+ throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
3594
+ }
3595
+ result.warnings.push(...validation.warnings);
3596
+ this.reportProgress("manifest", 1, 1, "Loading course manifest...");
3597
+ const manifest = await this.loadManifest(staticPath);
3598
+ logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
3599
+ this.reportProgress(
3600
+ "design_docs",
3601
+ 0,
3602
+ manifest.designDocs.length,
3603
+ "Restoring design documents..."
3604
+ );
3605
+ const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
3606
+ result.designDocsRestored = designDocResults.restored;
3607
+ result.errors.push(...designDocResults.errors);
3608
+ result.warnings.push(...designDocResults.warnings);
3609
+ this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
3610
+ const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
3611
+ result.courseConfigRestored = courseConfigResults.restored;
3612
+ result.errors.push(...courseConfigResults.errors);
3613
+ result.warnings.push(...courseConfigResults.warnings);
3614
+ this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
3615
+ const expectedCounts = this.calculateExpectedCounts(manifest);
3616
+ this.reportProgress(
3617
+ "documents",
3618
+ 0,
3619
+ manifest.documentCount,
3620
+ "Aggregating documents from chunks..."
3621
+ );
3622
+ const documents = await this.aggregateDocuments(staticPath, manifest);
3623
+ const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
3624
+ if (documents.length !== filteredDocuments.length) {
3625
+ result.warnings.push(
3626
+ `Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
3627
+ );
3628
+ }
3629
+ this.reportProgress(
3630
+ "documents",
3631
+ filteredDocuments.length,
3632
+ manifest.documentCount,
3633
+ "Uploading documents to CouchDB..."
3634
+ );
3635
+ const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
3636
+ result.documentsRestored = docResults.restored;
3637
+ result.errors.push(...docResults.errors);
3638
+ result.warnings.push(...docResults.warnings);
3639
+ const docsWithAttachments = documents.filter(
3640
+ (doc) => doc._attachments && Object.keys(doc._attachments).length > 0
3641
+ );
3642
+ this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
3643
+ const attachmentResults = await this.uploadAttachments(
3644
+ staticPath,
3645
+ docsWithAttachments,
3646
+ targetDB
3647
+ );
3648
+ result.attachmentsRestored = attachmentResults.restored;
3649
+ result.errors.push(...attachmentResults.errors);
3650
+ result.warnings.push(...attachmentResults.warnings);
3651
+ if (this.options.validateRoundTrip) {
3652
+ this.reportProgress("validation", 0, 1, "Validating migration...");
3653
+ const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
3654
+ if (!validationResult.valid) {
3655
+ result.warnings.push("Migration validation found issues");
3656
+ validationResult.issues.forEach((issue) => {
3657
+ if (issue.type === "error") {
3658
+ result.errors.push(`Validation: ${issue.message}`);
3659
+ } else {
3660
+ result.warnings.push(`Validation: ${issue.message}`);
3661
+ }
3662
+ });
3663
+ }
3664
+ this.reportProgress("validation", 1, 1, "Migration validation completed");
3665
+ }
3666
+ result.success = result.errors.length === 0;
3667
+ result.migrationTime = Date.now() - startTime;
3668
+ logger.info(`Migration completed in ${result.migrationTime}ms`);
3669
+ logger.info(`Documents restored: ${result.documentsRestored}`);
3670
+ logger.info(`Attachments restored: ${result.attachmentsRestored}`);
3671
+ logger.info(`Design docs restored: ${result.designDocsRestored}`);
3672
+ logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
3673
+ if (result.errors.length > 0) {
3674
+ logger.error(`Migration completed with ${result.errors.length} errors`);
3675
+ }
3676
+ if (result.warnings.length > 0) {
3677
+ logger.warn(`Migration completed with ${result.warnings.length} warnings`);
3678
+ }
3679
+ } catch (error) {
3680
+ result.success = false;
3681
+ result.migrationTime = Date.now() - startTime;
3682
+ const errorMessage = error instanceof Error ? error.message : String(error);
3683
+ result.errors.push(`Migration failed: ${errorMessage}`);
3684
+ logger.error("Migration failed:", error);
3685
+ if (this.options.cleanupOnFailure) {
3686
+ try {
3687
+ await this.cleanupFailedMigration(targetDB);
3688
+ } catch (cleanupError) {
3689
+ logger.error("Failed to cleanup after migration failure:", cleanupError);
3690
+ result.warnings.push("Failed to cleanup after migration failure");
3691
+ }
3692
+ }
3693
+ }
3694
+ return result;
3695
+ }
3696
+ /**
3697
+ * Load and parse the manifest file
3698
+ */
3699
+ async loadManifest(staticPath) {
3700
+ try {
3701
+ let manifestContent;
3702
+ let manifestPath;
3703
+ if (this.fs) {
3704
+ manifestPath = this.fs.joinPath(staticPath, "manifest.json");
3705
+ manifestContent = await this.fs.readFile(manifestPath);
3706
+ } else {
3707
+ manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
3708
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3709
+ manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
3710
+ } else {
3711
+ const response = await fetch(manifestPath);
3712
+ if (!response.ok) {
3713
+ throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
3714
+ }
3715
+ manifestContent = await response.text();
3716
+ }
3717
+ }
3718
+ const manifest = JSON.parse(manifestContent);
3719
+ if (!manifest.version || !manifest.courseId || !manifest.chunks) {
3720
+ throw new Error("Invalid manifest structure");
3721
+ }
3722
+ return manifest;
3723
+ } catch (error) {
3724
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
3725
+ throw new Error(errorMessage);
3726
+ }
3727
+ }
3728
+ /**
3729
+ * Restore design documents to CouchDB
3730
+ */
3731
+ async restoreDesignDocuments(designDocs, db) {
3732
+ const result = { restored: 0, errors: [], warnings: [] };
3733
+ for (let i = 0; i < designDocs.length; i++) {
3734
+ const designDoc = designDocs[i];
3735
+ this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
3736
+ try {
3737
+ let existingDoc;
3738
+ try {
3739
+ existingDoc = await db.get(designDoc._id);
3740
+ } catch {
3741
+ }
3742
+ const docToInsert = {
3743
+ _id: designDoc._id,
3744
+ views: designDoc.views
3745
+ };
3746
+ if (existingDoc) {
3747
+ docToInsert._rev = existingDoc._rev;
3748
+ logger.debug(`Updating existing design document: ${designDoc._id}`);
3749
+ } else {
3750
+ logger.debug(`Creating new design document: ${designDoc._id}`);
3751
+ }
3752
+ await db.put(docToInsert);
3753
+ result.restored++;
3754
+ } catch (error) {
3755
+ const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
3756
+ result.errors.push(errorMessage);
3757
+ logger.error(errorMessage);
3758
+ }
3759
+ }
3760
+ this.reportProgress(
3761
+ "design_docs",
3762
+ designDocs.length,
3763
+ designDocs.length,
3764
+ `Restored ${result.restored} design documents`
3765
+ );
3766
+ return result;
3767
+ }
3768
+ /**
3769
+ * Aggregate documents from all chunks
3770
+ */
3771
+ async aggregateDocuments(staticPath, manifest) {
3772
+ const allDocuments = [];
3773
+ const documentMap = /* @__PURE__ */ new Map();
3774
+ for (let i = 0; i < manifest.chunks.length; i++) {
3775
+ const chunk = manifest.chunks[i];
3776
+ this.reportProgress(
3777
+ "documents",
3778
+ allDocuments.length,
3779
+ manifest.documentCount,
3780
+ `Loading chunk ${chunk.id}...`
3781
+ );
3782
+ try {
3783
+ const documents = await this.loadChunk(staticPath, chunk);
3784
+ for (const doc of documents) {
3785
+ if (!doc._id) {
3786
+ logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
3787
+ continue;
3788
+ }
3789
+ if (documentMap.has(doc._id)) {
3790
+ logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
3791
+ }
3792
+ documentMap.set(doc._id, doc);
3793
+ }
3794
+ } catch (error) {
3795
+ throw new Error(
3796
+ `Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
3797
+ );
3798
+ }
3799
+ }
3800
+ allDocuments.push(...documentMap.values());
3801
+ logger.info(
3802
+ `Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
3803
+ );
3804
+ return allDocuments;
3805
+ }
3806
+ /**
3807
+ * Load documents from a single chunk file
3808
+ */
3809
+ async loadChunk(staticPath, chunk) {
3810
+ try {
3811
+ let chunkContent;
3812
+ let chunkPath;
3813
+ if (this.fs) {
3814
+ chunkPath = this.fs.joinPath(staticPath, chunk.path);
3815
+ chunkContent = await this.fs.readFile(chunkPath);
3816
+ } else {
3817
+ chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
3818
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3819
+ chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
3820
+ } else {
3821
+ const response = await fetch(chunkPath);
3822
+ if (!response.ok) {
3823
+ throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
3824
+ }
3825
+ chunkContent = await response.text();
3826
+ }
3827
+ }
3828
+ const documents = JSON.parse(chunkContent);
3829
+ if (!Array.isArray(documents)) {
3830
+ throw new Error("Chunk file does not contain an array of documents");
3831
+ }
3832
+ return documents;
3833
+ } catch (error) {
3834
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
3835
+ throw new Error(errorMessage);
3836
+ }
3837
+ }
3838
+ /**
3839
+ * Upload documents to CouchDB in batches
3840
+ */
3841
+ async uploadDocuments(documents, db) {
3842
+ const result = { restored: 0, errors: [], warnings: [] };
3843
+ const batchSize = this.options.chunkBatchSize;
3844
+ for (let i = 0; i < documents.length; i += batchSize) {
3845
+ const batch = documents.slice(i, i + batchSize);
3846
+ this.reportProgress(
3847
+ "documents",
3848
+ i,
3849
+ documents.length,
3850
+ `Uploading batch ${Math.floor(i / batchSize) + 1}...`
3851
+ );
3852
+ try {
3853
+ const docsToInsert = batch.map((doc) => {
3854
+ const cleanDoc = { ...doc };
3855
+ delete cleanDoc._rev;
3856
+ delete cleanDoc._attachments;
3857
+ return cleanDoc;
3858
+ });
3859
+ const bulkResult = await db.bulkDocs(docsToInsert);
3860
+ for (let j = 0; j < bulkResult.length; j++) {
3861
+ const docResult = bulkResult[j];
3862
+ const originalDoc = batch[j];
3863
+ if ("error" in docResult) {
3864
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
3865
+ result.errors.push(errorMessage);
3866
+ logger.error(errorMessage);
3867
+ } else {
3868
+ result.restored++;
3869
+ }
3870
+ }
3871
+ } catch (error) {
3872
+ let errorMessage;
3873
+ if (error instanceof Error) {
3874
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3875
+ } else if (error && typeof error === "object" && "message" in error) {
3876
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3877
+ } else {
3878
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
3879
+ }
3880
+ result.errors.push(errorMessage);
3881
+ logger.error(errorMessage);
3882
+ }
3883
+ }
3884
+ this.reportProgress(
3885
+ "documents",
3886
+ documents.length,
3887
+ documents.length,
3888
+ `Uploaded ${result.restored} documents`
3889
+ );
3890
+ return result;
3891
+ }
3892
+ /**
3893
+ * Upload attachments from filesystem to CouchDB
3894
+ */
3895
+ async uploadAttachments(staticPath, documents, db) {
3896
+ const result = { restored: 0, errors: [], warnings: [] };
3897
+ let processedDocs = 0;
3898
+ for (const doc of documents) {
3899
+ this.reportProgress(
3900
+ "attachments",
3901
+ processedDocs,
3902
+ documents.length,
3903
+ `Processing attachments for ${doc._id}...`
3904
+ );
3905
+ processedDocs++;
3906
+ if (!doc._attachments) {
3907
+ continue;
3908
+ }
3909
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
3910
+ try {
3911
+ const uploadResult = await this.uploadSingleAttachment(
3912
+ staticPath,
3913
+ doc._id,
3914
+ attachmentName,
3915
+ attachmentMeta,
3916
+ db
3917
+ );
3918
+ if (uploadResult.success) {
3919
+ result.restored++;
3920
+ } else {
3921
+ result.errors.push(uploadResult.error || "Unknown attachment upload error");
3922
+ }
3923
+ } catch (error) {
3924
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
3925
+ result.errors.push(errorMessage);
3926
+ logger.error(errorMessage);
3927
+ }
3928
+ }
3929
+ }
3930
+ this.reportProgress(
3931
+ "attachments",
3932
+ documents.length,
3933
+ documents.length,
3934
+ `Uploaded ${result.restored} attachments`
3935
+ );
3936
+ return result;
3937
+ }
3938
+ /**
3939
+ * Upload a single attachment file
3940
+ */
3941
+ async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
3942
+ const result = {
3943
+ success: false,
3944
+ attachmentName,
3945
+ docId
3946
+ };
3947
+ try {
3948
+ if (!attachmentMeta.path) {
3949
+ result.error = "Attachment metadata missing file path";
3950
+ return result;
3951
+ }
3952
+ let attachmentData;
3953
+ let attachmentPath;
3954
+ if (this.fs) {
3955
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
3956
+ attachmentData = await this.fs.readBinary(attachmentPath);
3957
+ } else {
3958
+ attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
3959
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3960
+ attachmentData = await nodeFS2.promises.readFile(attachmentPath);
3961
+ } else {
3962
+ const response = await fetch(attachmentPath);
3963
+ if (!response.ok) {
3964
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
3965
+ return result;
3966
+ }
3967
+ attachmentData = await response.arrayBuffer();
3968
+ }
3969
+ }
3970
+ const doc = await db.get(docId);
3971
+ await db.putAttachment(
3972
+ docId,
3973
+ attachmentName,
3974
+ doc._rev,
3975
+ attachmentData,
3976
+ // PouchDB accepts both ArrayBuffer and Buffer
3977
+ attachmentMeta.content_type
3978
+ );
3979
+ result.success = true;
3980
+ } catch (error) {
3981
+ result.error = error instanceof Error ? error.message : String(error);
3982
+ }
3983
+ return result;
3984
+ }
3985
+ /**
3986
+ * Restore CourseConfig document from manifest
3987
+ */
3988
+ async restoreCourseConfig(manifest, targetDB) {
3989
+ const results = {
3990
+ restored: 0,
3991
+ errors: [],
3992
+ warnings: []
3993
+ };
3994
+ try {
3995
+ if (!manifest.courseConfig) {
3996
+ results.warnings.push(
3997
+ "No courseConfig found in manifest, skipping CourseConfig document creation"
3998
+ );
3999
+ return results;
4000
+ }
4001
+ const courseConfigDoc = {
4002
+ _id: "CourseConfig",
4003
+ ...manifest.courseConfig,
4004
+ courseID: manifest.courseId
4005
+ };
4006
+ delete courseConfigDoc._rev;
4007
+ await targetDB.put(courseConfigDoc);
4008
+ results.restored = 1;
4009
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
4010
+ } catch (error) {
4011
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
4012
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
4013
+ logger.error("CourseConfig restoration failed:", error);
4014
+ }
4015
+ return results;
4016
+ }
4017
+ /**
4018
+ * Calculate expected document counts from manifest
4019
+ */
4020
+ calculateExpectedCounts(manifest) {
4021
+ const counts = {};
4022
+ for (const chunk of manifest.chunks) {
4023
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
4024
+ }
4025
+ if (manifest.designDocs.length > 0) {
4026
+ counts["_design"] = manifest.designDocs.length;
4027
+ }
4028
+ return counts;
4029
+ }
4030
+ /**
4031
+ * Clean up database after failed migration
4032
+ */
4033
+ async cleanupFailedMigration(db) {
4034
+ logger.info("Cleaning up failed migration...");
4035
+ try {
4036
+ const allDocs = await db.allDocs();
4037
+ const docsToDelete = allDocs.rows.map((row) => ({
4038
+ _id: row.id,
4039
+ _rev: row.value.rev,
4040
+ _deleted: true
4041
+ }));
4042
+ if (docsToDelete.length > 0) {
4043
+ await db.bulkDocs(docsToDelete);
4044
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
4045
+ }
4046
+ } catch (error) {
4047
+ logger.error("Failed to cleanup documents:", error);
4048
+ throw error;
4049
+ }
4050
+ }
4051
+ /**
4052
+ * Report progress to callback if available
4053
+ */
4054
+ reportProgress(phase, current, total, message) {
4055
+ if (this.progressCallback) {
4056
+ this.progressCallback({
4057
+ phase,
4058
+ current,
4059
+ total,
4060
+ message
4061
+ });
4062
+ }
4063
+ }
4064
+ /**
4065
+ * Check if a path is a local file path (vs URL)
4066
+ */
4067
+ isLocalPath(path2) {
4068
+ return !path2.startsWith("http://") && !path2.startsWith("https://");
4069
+ }
4070
+ };
4071
+ }
4072
+ });
4073
+
4074
+ // src/util/migrator/index.ts
4075
+ var init_migrator = __esm({
4076
+ "src/util/migrator/index.ts"() {
4077
+ "use strict";
4078
+ init_StaticToCouchDBMigrator();
4079
+ init_validation();
4080
+ init_FileSystemAdapter();
4081
+ }
4082
+ });
4083
+
4084
+ // src/util/dataDirectory.ts
4085
+ function getAppDataDirectory() {
4086
+ if (ENV.LOCAL_STORAGE_PREFIX) {
4087
+ return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
4088
+ } else {
4089
+ return path.join(os.homedir(), ".tuilder");
4090
+ }
4091
+ }
4092
+ function getDbPath(dbName) {
4093
+ return path.join(getAppDataDirectory(), dbName);
4094
+ }
4095
+ var path, os;
4096
+ var init_dataDirectory = __esm({
4097
+ "src/util/dataDirectory.ts"() {
4098
+ "use strict";
4099
+ path = __toESM(require("path"), 1);
4100
+ os = __toESM(require("os"), 1);
4101
+ init_logger();
4102
+ init_factory();
4103
+ }
4104
+ });
4105
+
4106
+ // src/util/index.ts
4107
+ var init_util2 = __esm({
4108
+ "src/util/index.ts"() {
4109
+ "use strict";
4110
+ init_Loggable();
4111
+ init_packer();
4112
+ init_migrator();
4113
+ init_dataDirectory();
4114
+ }
4115
+ });
4116
+
4117
+ // src/study/SourceMixer.ts
4118
+ var init_SourceMixer = __esm({
4119
+ "src/study/SourceMixer.ts"() {
4120
+ "use strict";
4121
+ }
4122
+ });
4123
+
4124
+ // src/study/MixerDebugger.ts
4125
+ function printMixerSummary(run) {
4126
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
4127
+ logger.info(`Run ID: ${run.runId}`);
4128
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
4129
+ logger.info(
4130
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
4131
+ );
4132
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
4133
+ for (const src of run.sourceSummaries) {
4134
+ logger.info(
4135
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
4136
+ );
4137
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
4138
+ }
4139
+ console.groupEnd();
4140
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
4141
+ for (const breakdown of run.sourceBreakdowns) {
4142
+ const name = breakdown.sourceName || breakdown.sourceId;
4143
+ logger.info(
4144
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
4145
+ );
4146
+ }
4147
+ console.groupEnd();
4148
+ console.groupEnd();
4149
+ }
4150
+ function mountMixerDebugger() {
4151
+ if (typeof window === "undefined") return;
4152
+ const win = window;
4153
+ win.skuilder = win.skuilder || {};
4154
+ win.skuilder.mixer = mixerDebugAPI;
4155
+ }
4156
+ var runHistory2, mixerDebugAPI;
4157
+ var init_MixerDebugger = __esm({
4158
+ "src/study/MixerDebugger.ts"() {
4159
+ "use strict";
4160
+ init_logger();
4161
+ init_navigators();
4162
+ runHistory2 = [];
4163
+ mixerDebugAPI = {
4164
+ /**
4165
+ * Get raw run history for programmatic access.
4166
+ */
4167
+ get runs() {
4168
+ return [...runHistory2];
4169
+ },
4170
+ /**
4171
+ * Show summary of a specific mixer run.
4172
+ */
4173
+ showRun(idOrIndex = 0) {
4174
+ if (runHistory2.length === 0) {
4175
+ logger.info("[Mixer Debug] No runs captured yet.");
4176
+ return;
4177
+ }
4178
+ let run;
4179
+ if (typeof idOrIndex === "number") {
4180
+ run = runHistory2[idOrIndex];
4181
+ if (!run) {
4182
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
4183
+ return;
4184
+ }
4185
+ } else {
4186
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
4187
+ if (!run) {
4188
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
4189
+ return;
4190
+ }
4191
+ }
4192
+ printMixerSummary(run);
4193
+ },
4194
+ /**
4195
+ * Show summary of the last mixer run.
4196
+ */
4197
+ showLastMix() {
4198
+ this.showRun(0);
4199
+ },
4200
+ /**
4201
+ * Explain source balance in the last run.
4202
+ */
4203
+ explainSourceBalance() {
4204
+ if (runHistory2.length === 0) {
4205
+ logger.info("[Mixer Debug] No runs captured yet.");
4206
+ return;
4207
+ }
4208
+ const run = runHistory2[0];
4209
+ console.group("\u2696\uFE0F Source Balance Analysis");
4210
+ logger.info(`Mixer: ${run.mixerType}`);
4211
+ logger.info(`Requested limit: ${run.requestedLimit}`);
4212
+ if (run.quotaPerSource) {
4213
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
4214
+ }
4215
+ console.group("Input Distribution:");
4216
+ for (const src of run.sourceSummaries) {
4217
+ const name = src.sourceName || src.sourceId;
4218
+ logger.info(`${name}:`);
4219
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
4220
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
4221
+ }
4222
+ console.groupEnd();
4223
+ console.group("Selection Results:");
4224
+ for (const breakdown of run.sourceBreakdowns) {
4225
+ const name = breakdown.sourceName || breakdown.sourceId;
4226
+ logger.info(`${name}:`);
4227
+ logger.info(
4228
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
4229
+ );
4230
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
4231
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
4232
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
4233
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
4234
+ }
4235
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
4236
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
4237
+ }
4238
+ }
4239
+ console.groupEnd();
4240
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
4241
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
4242
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
4243
+ if (maxDeviation > 20) {
4244
+ logger.info(`
4245
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
4246
+ logger.info("Possible causes:");
4247
+ logger.info(" - Score range differences between sources");
4248
+ logger.info(" - One source has much better quality cards");
4249
+ logger.info(" - Different card availability (reviews vs new)");
4250
+ }
4251
+ console.groupEnd();
4252
+ },
4253
+ /**
4254
+ * Compare score distributions across sources.
4255
+ */
4256
+ compareScores() {
4257
+ if (runHistory2.length === 0) {
4258
+ logger.info("[Mixer Debug] No runs captured yet.");
4259
+ return;
4260
+ }
4261
+ const run = runHistory2[0];
4262
+ console.group("\u{1F4CA} Score Distribution Comparison");
4263
+ console.table(
4264
+ run.sourceSummaries.map((src) => ({
4265
+ source: src.sourceName || src.sourceId,
4266
+ cards: src.totalCards,
4267
+ min: src.bottomScore.toFixed(3),
4268
+ max: src.topScore.toFixed(3),
4269
+ avg: src.avgScore.toFixed(3),
4270
+ range: (src.topScore - src.bottomScore).toFixed(3)
4271
+ }))
4272
+ );
4273
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
4274
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
4275
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
4276
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
4277
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
4278
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
4279
+ logger.info(
4280
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
4281
+ );
4282
+ }
4283
+ console.groupEnd();
4284
+ },
4285
+ /**
4286
+ * Show detailed information for a specific card.
4287
+ */
4288
+ showCard(cardId) {
4289
+ for (const run of runHistory2) {
4290
+ const card = run.cards.find((c) => c.cardId === cardId);
4291
+ if (card) {
4292
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
4293
+ console.group(`\u{1F3B4} Card: ${cardId}`);
4294
+ logger.info(`Course: ${card.courseId}`);
4295
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
4296
+ logger.info(`Origin: ${card.origin}`);
4297
+ logger.info(`Score: ${card.score.toFixed(3)}`);
4298
+ if (card.rankInSource) {
4299
+ logger.info(`Rank in source: #${card.rankInSource}`);
4300
+ }
4301
+ if (card.rankInMix) {
4302
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
4303
+ }
4304
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
4305
+ if (!card.selected && card.rankInSource) {
4306
+ logger.info("\nWhy not selected:");
4307
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
4308
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
4309
+ }
4310
+ logger.info(" - Check score compared to selected cards using .showRun()");
4311
+ }
4312
+ console.groupEnd();
4313
+ return;
4314
+ }
4315
+ }
4316
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
4317
+ },
4318
+ /**
4319
+ * Show all runs in compact format.
4320
+ */
4321
+ listRuns() {
4322
+ if (runHistory2.length === 0) {
4323
+ logger.info("[Mixer Debug] No runs captured yet.");
4324
+ return;
4325
+ }
4326
+ console.table(
4327
+ runHistory2.map((r) => ({
4328
+ id: r.runId.slice(-8),
4329
+ time: r.timestamp.toLocaleTimeString(),
4330
+ mixer: r.mixerType,
4331
+ sources: r.sourceSummaries.length,
4332
+ selected: r.finalCount,
4333
+ reviews: r.reviewsSelected,
4334
+ new: r.newSelected
4335
+ }))
4336
+ );
4337
+ },
4338
+ /**
4339
+ * Export run history as JSON for bug reports.
4340
+ */
4341
+ export() {
4342
+ const json = JSON.stringify(runHistory2, null, 2);
4343
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
4344
+ logger.info(" copy(window.skuilder.mixer.export())");
4345
+ return json;
4346
+ },
4347
+ /**
4348
+ * Clear run history.
4349
+ */
4350
+ clear() {
4351
+ runHistory2.length = 0;
4352
+ logger.info("[Mixer Debug] Run history cleared.");
4353
+ },
4354
+ /**
4355
+ * Show help.
4356
+ */
4357
+ help() {
4358
+ logger.info(`
4359
+ \u{1F3A8} Mixer Debug API
4360
+
4361
+ Commands:
4362
+ .showLastMix() Show summary of most recent mixer run
4363
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
4364
+ .explainSourceBalance() Analyze source balance and selection patterns
4365
+ .compareScores() Compare score distributions across sources
4366
+ .showCard(cardId) Show mixer decisions for a specific card
4367
+ .listRuns() List all captured runs in table format
4368
+ .export() Export run history as JSON for bug reports
4369
+ .clear() Clear run history
4370
+ .runs Access raw run history array
4371
+ .help() Show this help message
2490
4372
 
2491
- // src/core/orchestration/recording.ts
2492
- var init_recording = __esm({
2493
- "src/core/orchestration/recording.ts"() {
2494
- "use strict";
2495
- init_signal();
2496
- init_types_legacy();
2497
- init_logger();
4373
+ Example:
4374
+ window.skuilder.mixer.showLastMix()
4375
+ window.skuilder.mixer.explainSourceBalance()
4376
+ window.skuilder.mixer.compareScores()
4377
+ `);
4378
+ }
4379
+ };
4380
+ mountMixerDebugger();
2498
4381
  }
2499
4382
  });
2500
4383
 
2501
- // src/core/orchestration/index.ts
2502
- function fnv1a(str) {
2503
- let hash = 2166136261;
2504
- for (let i = 0; i < str.length; i++) {
2505
- hash ^= str.charCodeAt(i);
2506
- hash = Math.imul(hash, 16777619);
4384
+ // src/study/SessionDebugger.ts
4385
+ function showCurrentQueue() {
4386
+ if (!activeSession) {
4387
+ logger.info("[Session Debug] No active session.");
4388
+ return;
2507
4389
  }
2508
- return hash >>> 0;
2509
- }
2510
- function computeDeviation(userId, strategyId, salt) {
2511
- const input = `${userId}:${strategyId}:${salt}`;
2512
- const hash = fnv1a(input);
2513
- const normalized = hash / 4294967296;
2514
- return normalized * 2 - 1;
2515
- }
2516
- function computeSpread(confidence) {
2517
- const clampedConfidence = Math.max(0, Math.min(1, confidence));
2518
- return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
4390
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
4391
+ console.group("\u{1F4CA} Current Queue State");
4392
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
4393
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
4394
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
4395
+ }
4396
+ logger.info(`New Queue: ${latest.newQLength} cards`);
4397
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
4398
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
4399
+ }
4400
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
4401
+ console.groupEnd();
2519
4402
  }
2520
- function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2521
- const deviation = computeDeviation(userId, strategyId, salt);
2522
- const spread = computeSpread(learnable.confidence);
2523
- const adjustment = deviation * spread * learnable.weight;
2524
- const effective = learnable.weight + adjustment;
2525
- return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
4403
+ function showPresentationHistory(sessionIndex = 0) {
4404
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4405
+ if (!session) {
4406
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4407
+ return;
4408
+ }
4409
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
4410
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
4411
+ if (session.endTime) {
4412
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
4413
+ }
4414
+ logger.info(`Cards presented: ${session.presentations.length}`);
4415
+ if (session.presentations.length > 0) {
4416
+ console.table(
4417
+ session.presentations.map((p) => ({
4418
+ "#": p.sequenceNumber,
4419
+ course: p.courseName || p.courseId.slice(0, 8),
4420
+ origin: p.origin,
4421
+ queue: p.queueSource,
4422
+ score: p.score?.toFixed(3) || "-",
4423
+ time: p.timestamp.toLocaleTimeString()
4424
+ }))
4425
+ );
4426
+ }
4427
+ console.groupEnd();
2526
4428
  }
2527
- async function createOrchestrationContext(user, course) {
2528
- let courseConfig;
2529
- try {
2530
- courseConfig = await course.getCourseConfig();
2531
- } catch (e) {
2532
- logger.error(`[Orchestration] Failed to load course config: ${e}`);
2533
- courseConfig = {
2534
- name: "Unknown",
2535
- description: "",
2536
- public: false,
2537
- deleted: false,
2538
- creator: "",
2539
- admins: [],
2540
- moderators: [],
2541
- dataShapes: [],
2542
- questionTypes: [],
2543
- orchestration: { salt: "default" }
2544
- };
4429
+ function showInterleaving(sessionIndex = 0) {
4430
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4431
+ if (!session) {
4432
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4433
+ return;
2545
4434
  }
2546
- const userId = user.getUsername();
2547
- const salt = courseConfig.orchestration?.salt || "default_salt";
2548
- return {
2549
- user,
2550
- course,
2551
- userId,
2552
- courseConfig,
2553
- getEffectiveWeight(strategyId, learnable) {
2554
- return computeEffectiveWeight(learnable, userId, strategyId, salt);
2555
- },
2556
- getDeviation(strategyId) {
2557
- return computeDeviation(userId, strategyId, salt);
4435
+ console.group("\u{1F500} Interleaving Analysis");
4436
+ const courseCounts = /* @__PURE__ */ new Map();
4437
+ const courseOrigins = /* @__PURE__ */ new Map();
4438
+ session.presentations.forEach((p) => {
4439
+ const name = p.courseName || p.courseId;
4440
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
4441
+ if (!courseOrigins.has(name)) {
4442
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
2558
4443
  }
2559
- };
4444
+ const origins = courseOrigins.get(name);
4445
+ origins[p.origin]++;
4446
+ });
4447
+ logger.info("Course distribution:");
4448
+ console.table(
4449
+ Array.from(courseCounts.entries()).map(([course, count]) => {
4450
+ const origins = courseOrigins.get(course);
4451
+ return {
4452
+ course,
4453
+ total: count,
4454
+ reviews: origins.review,
4455
+ new: origins.new,
4456
+ failed: origins.failed,
4457
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
4458
+ };
4459
+ })
4460
+ );
4461
+ if (session.presentations.length > 0) {
4462
+ logger.info("\nPresentation sequence (first 20):");
4463
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
4464
+ logger.info(sequence);
4465
+ }
4466
+ let maxCluster = 0;
4467
+ let currentCluster = 1;
4468
+ let currentCourse = session.presentations[0]?.courseId;
4469
+ for (let i = 1; i < session.presentations.length; i++) {
4470
+ if (session.presentations[i].courseId === currentCourse) {
4471
+ currentCluster++;
4472
+ maxCluster = Math.max(maxCluster, currentCluster);
4473
+ } else {
4474
+ currentCourse = session.presentations[i].courseId;
4475
+ currentCluster = 1;
4476
+ }
4477
+ }
4478
+ if (maxCluster > 3) {
4479
+ logger.info(`
4480
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
4481
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
4482
+ }
4483
+ console.groupEnd();
2560
4484
  }
2561
- var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2562
- var init_orchestration = __esm({
2563
- "src/core/orchestration/index.ts"() {
4485
+ function mountSessionDebugger() {
4486
+ if (typeof window === "undefined") return;
4487
+ const win = window;
4488
+ win.skuilder = win.skuilder || {};
4489
+ win.skuilder.session = sessionDebugAPI;
4490
+ }
4491
+ var activeSession, sessionHistory, sessionDebugAPI;
4492
+ var init_SessionDebugger = __esm({
4493
+ "src/study/SessionDebugger.ts"() {
2564
4494
  "use strict";
2565
4495
  init_logger();
2566
- init_gradient();
2567
- init_learning();
2568
- init_signal();
4496
+ activeSession = null;
4497
+ sessionHistory = [];
4498
+ sessionDebugAPI = {
4499
+ /**
4500
+ * Get raw session history for programmatic access.
4501
+ */
4502
+ get sessions() {
4503
+ return [...sessionHistory];
4504
+ },
4505
+ /**
4506
+ * Get active session if any.
4507
+ */
4508
+ get active() {
4509
+ return activeSession;
4510
+ },
4511
+ /**
4512
+ * Show current queue state.
4513
+ */
4514
+ showQueue() {
4515
+ showCurrentQueue();
4516
+ },
4517
+ /**
4518
+ * Show presentation history for current or past session.
4519
+ */
4520
+ showHistory(sessionIndex = 0) {
4521
+ showPresentationHistory(sessionIndex);
4522
+ },
4523
+ /**
4524
+ * Analyze course interleaving pattern.
4525
+ */
4526
+ showInterleaving(sessionIndex = 0) {
4527
+ showInterleaving(sessionIndex);
4528
+ },
4529
+ /**
4530
+ * List all tracked sessions.
4531
+ */
4532
+ listSessions() {
4533
+ if (activeSession) {
4534
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
4535
+ }
4536
+ if (sessionHistory.length === 0) {
4537
+ logger.info("[Session Debug] No completed sessions in history.");
4538
+ return;
4539
+ }
4540
+ console.table(
4541
+ sessionHistory.map((s, idx) => ({
4542
+ index: idx,
4543
+ id: s.sessionId.slice(-8),
4544
+ started: s.startTime.toLocaleTimeString(),
4545
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
4546
+ cards: s.presentations.length
4547
+ }))
4548
+ );
4549
+ },
4550
+ /**
4551
+ * Export session history as JSON for bug reports.
4552
+ */
4553
+ export() {
4554
+ const data = {
4555
+ active: activeSession,
4556
+ history: sessionHistory
4557
+ };
4558
+ const json = JSON.stringify(data, null, 2);
4559
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
4560
+ logger.info(" copy(window.skuilder.session.export())");
4561
+ return json;
4562
+ },
4563
+ /**
4564
+ * Clear session history.
4565
+ */
4566
+ clear() {
4567
+ sessionHistory.length = 0;
4568
+ logger.info("[Session Debug] Session history cleared.");
4569
+ },
4570
+ /**
4571
+ * Show help.
4572
+ */
4573
+ help() {
4574
+ logger.info(`
4575
+ \u{1F3AF} Session Debug API
4576
+
4577
+ Commands:
4578
+ .showQueue() Show current queue state (active session only)
4579
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
4580
+ .showInterleaving(index?) Analyze course interleaving pattern
4581
+ .listSessions() List all tracked sessions
4582
+ .export() Export session data as JSON for bug reports
4583
+ .clear() Clear session history
4584
+ .sessions Access raw session history array
4585
+ .active Access active session (if any)
4586
+ .help() Show this help message
4587
+
4588
+ Example:
4589
+ window.skuilder.session.showHistory()
4590
+ window.skuilder.session.showInterleaving()
4591
+ window.skuilder.session.showQueue()
4592
+ `);
4593
+ }
4594
+ };
4595
+ mountSessionDebugger();
4596
+ }
4597
+ });
4598
+
4599
+ // src/study/SessionController.ts
4600
+ var init_SessionController = __esm({
4601
+ "src/study/SessionController.ts"() {
4602
+ "use strict";
4603
+ init_SrsService();
4604
+ init_EloService();
4605
+ init_ResponseProcessor();
4606
+ init_CardHydrationService();
4607
+ init_ItemQueue();
4608
+ init_couch();
2569
4609
  init_recording();
2570
- MIN_SPREAD = 0.1;
2571
- MAX_SPREAD = 0.5;
2572
- MIN_WEIGHT = 0.1;
2573
- MAX_WEIGHT = 3;
4610
+ init_util2();
4611
+ init_navigators();
4612
+ init_SourceMixer();
4613
+ init_MixerDebugger();
4614
+ init_SessionDebugger();
4615
+ init_logger();
2574
4616
  }
2575
4617
  });
2576
4618
 
@@ -2654,15 +4696,16 @@ function logCardProvenance(cards, maxCards = 3) {
2654
4696
  }
2655
4697
  }
2656
4698
  }
2657
- var import_common8, VERBOSE_RESULTS, Pipeline;
4699
+ var import_common12, VERBOSE_RESULTS, Pipeline;
2658
4700
  var init_Pipeline = __esm({
2659
4701
  "src/core/navigators/Pipeline.ts"() {
2660
4702
  "use strict";
2661
- import_common8 = require("@vue-skuilder/common");
4703
+ import_common12 = require("@vue-skuilder/common");
2662
4704
  init_navigators();
2663
4705
  init_logger();
2664
4706
  init_orchestration();
2665
4707
  init_PipelineDebugger();
4708
+ init_SessionController();
2666
4709
  VERBOSE_RESULTS = true;
2667
4710
  Pipeline = class extends ContentNavigator {
2668
4711
  generator;
@@ -2822,8 +4865,9 @@ var init_Pipeline = __esm({
2822
4865
  generatorSummaries,
2823
4866
  generatedCount,
2824
4867
  filterImpacts,
2825
- allCardsBeforeFiltering,
2826
- result
4868
+ cards,
4869
+ result,
4870
+ context.userElo
2827
4871
  );
2828
4872
  captureRun(report);
2829
4873
  } catch (e) {
@@ -2984,7 +5028,7 @@ var init_Pipeline = __esm({
2984
5028
  let userElo = 1e3;
2985
5029
  try {
2986
5030
  const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
2987
- const courseElo = (0, import_common8.toCourseElo)(courseReg.elo);
5031
+ const courseElo = (0, import_common12.toCourseElo)(courseReg.elo);
2988
5032
  userElo = courseElo.global.score;
2989
5033
  } catch (e) {
2990
5034
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
@@ -3037,6 +5081,34 @@ var init_Pipeline = __esm({
3037
5081
  return [...new Set(ids)];
3038
5082
  }
3039
5083
  // ---------------------------------------------------------------------------
5084
+ // Tag ELO diagnostic
5085
+ // ---------------------------------------------------------------------------
5086
+ /**
5087
+ * Get the user's per-tag ELO data for specified tags (or all tags).
5088
+ * Useful for diagnosing why hierarchy gates are open/closed.
5089
+ */
5090
+ async getTagEloStatus(tagFilter) {
5091
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
5092
+ const courseElo = (0, import_common12.toCourseElo)(courseReg.elo);
5093
+ const result = {};
5094
+ if (!tagFilter) {
5095
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5096
+ result[tag] = { score: data.score, count: data.count };
5097
+ }
5098
+ } else {
5099
+ const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
5100
+ for (const pattern of patterns) {
5101
+ const regex = globToRegex(pattern);
5102
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5103
+ if (regex.test(tag)) {
5104
+ result[tag] = { score: data.score, count: data.count };
5105
+ }
5106
+ }
5107
+ }
5108
+ }
5109
+ return result;
5110
+ }
5111
+ // ---------------------------------------------------------------------------
3040
5112
  // Card-space diagnostic
3041
5113
  // ---------------------------------------------------------------------------
3042
5114
  /**
@@ -3732,11 +5804,11 @@ ${JSON.stringify(config)}
3732
5804
  function isSuccessRow(row) {
3733
5805
  return "doc" in row && row.doc !== null && row.doc !== void 0;
3734
5806
  }
3735
- var import_common9, CoursesDB, CourseDB;
5807
+ var import_common13, CoursesDB, CourseDB;
3736
5808
  var init_courseDB = __esm({
3737
5809
  "src/impl/couch/courseDB.ts"() {
3738
5810
  "use strict";
3739
- import_common9 = require("@vue-skuilder/common");
5811
+ import_common13 = require("@vue-skuilder/common");
3740
5812
  init_couch();
3741
5813
  init_updateQueue();
3742
5814
  init_types_legacy();
@@ -3885,14 +5957,14 @@ var init_courseDB = __esm({
3885
5957
  docs.rows.forEach((r) => {
3886
5958
  if (isSuccessRow(r)) {
3887
5959
  if (r.doc && r.doc.elo) {
3888
- ret.push((0, import_common9.toCourseElo)(r.doc.elo));
5960
+ ret.push((0, import_common13.toCourseElo)(r.doc.elo));
3889
5961
  } else {
3890
5962
  logger.warn("no elo data for card: " + r.id);
3891
- ret.push((0, import_common9.blankCourseElo)());
5963
+ ret.push((0, import_common13.blankCourseElo)());
3892
5964
  }
3893
5965
  } else {
3894
5966
  logger.warn("no elo data for card: " + JSON.stringify(r));
3895
- ret.push((0, import_common9.blankCourseElo)());
5967
+ ret.push((0, import_common13.blankCourseElo)());
3896
5968
  }
3897
5969
  });
3898
5970
  return ret;
@@ -4094,7 +6166,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4094
6166
  async getCourseTagStubs() {
4095
6167
  return getCourseTagStubs(this.id);
4096
6168
  }
4097
- async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common9.blankCourseElo)()) {
6169
+ async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common13.blankCourseElo)()) {
4098
6170
  try {
4099
6171
  const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
4100
6172
  if (resp.ok) {
@@ -4103,19 +6175,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4103
6175
  `[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
4104
6176
  );
4105
6177
  return {
4106
- status: import_common9.Status.error,
6178
+ status: import_common13.Status.error,
4107
6179
  message: `Note was added but no cards were created: ${resp.cardCreationError}`,
4108
6180
  id: resp.id
4109
6181
  };
4110
6182
  }
4111
6183
  return {
4112
- status: import_common9.Status.ok,
6184
+ status: import_common13.Status.ok,
4113
6185
  message: "",
4114
6186
  id: resp.id
4115
6187
  };
4116
6188
  } else {
4117
6189
  return {
4118
- status: import_common9.Status.error,
6190
+ status: import_common13.Status.error,
4119
6191
  message: "Unexpected error adding note"
4120
6192
  };
4121
6193
  }
@@ -4127,7 +6199,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4127
6199
  message: ${err.message}`
4128
6200
  );
4129
6201
  return {
4130
- status: import_common9.Status.error,
6202
+ status: import_common13.Status.error,
4131
6203
  message: `Error adding note to course. ${e.reason || err.message}`
4132
6204
  };
4133
6205
  }
@@ -4266,7 +6338,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4266
6338
  const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
4267
6339
  return c.courseID === this.id;
4268
6340
  });
4269
- targetElo = (0, import_common9.EloToNumber)(courseDoc.elo);
6341
+ targetElo = (0, import_common13.EloToNumber)(courseDoc.elo);
4270
6342
  } catch {
4271
6343
  targetElo = 1e3;
4272
6344
  }
@@ -4401,13 +6473,13 @@ function getClassroomDB(classID, version) {
4401
6473
  async function getClassroomConfig(classID) {
4402
6474
  return await getClassroomDB(classID, "student").get(CLASSROOM_CONFIG);
4403
6475
  }
4404
- var import_moment2, classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
6476
+ var import_moment4, classroomLookupDBTitle, CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB, TeacherClassroomDB, ClassroomLookupDB;
4405
6477
  var init_classroomDB2 = __esm({
4406
6478
  "src/impl/couch/classroomDB.ts"() {
4407
6479
  "use strict";
4408
6480
  init_factory();
4409
6481
  init_logger();
4410
- import_moment2 = __toESM(require("moment"), 1);
6482
+ import_moment4 = __toESM(require("moment"), 1);
4411
6483
  init_pouchdb_setup();
4412
6484
  init_couch();
4413
6485
  init_courseDB();
@@ -4520,9 +6592,9 @@ var init_classroomDB2 = __esm({
4520
6592
  }
4521
6593
  const activeCards = await this._user.getActiveCards();
4522
6594
  const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
4523
- const now = import_moment2.default.utc();
6595
+ const now = import_moment4.default.utc();
4524
6596
  const assigned = await this.getAssignedContent();
4525
- const due = assigned.filter((c) => now.isAfter(import_moment2.default.utc(c.activeOn, REVIEW_TIME_FORMAT)));
6597
+ const due = assigned.filter((c) => now.isAfter(import_moment4.default.utc(c.activeOn, REVIEW_TIME_FORMAT)));
4526
6598
  logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
4527
6599
  for (const content of due) {
4528
6600
  if (content.type === "course") {
@@ -4648,8 +6720,8 @@ var init_classroomDB2 = __esm({
4648
6720
  type: "tag",
4649
6721
  _id: id,
4650
6722
  assignedBy: content.assignedBy,
4651
- assignedOn: import_moment2.default.utc(),
4652
- activeOn: content.activeOn || import_moment2.default.utc()
6723
+ assignedOn: import_moment4.default.utc(),
6724
+ activeOn: content.activeOn || import_moment4.default.utc()
4653
6725
  });
4654
6726
  } else {
4655
6727
  put = await this._db.put({
@@ -4657,8 +6729,8 @@ var init_classroomDB2 = __esm({
4657
6729
  type: "course",
4658
6730
  _id: id,
4659
6731
  assignedBy: content.assignedBy,
4660
- assignedOn: import_moment2.default.utc(),
4661
- activeOn: content.activeOn || import_moment2.default.utc()
6732
+ assignedOn: import_moment4.default.utc(),
6733
+ activeOn: content.activeOn || import_moment4.default.utc()
4662
6734
  });
4663
6735
  }
4664
6736
  if (put.ok) {
@@ -4678,11 +6750,11 @@ var init_classroomDB2 = __esm({
4678
6750
  });
4679
6751
 
4680
6752
  // src/study/TagFilteredContentSource.ts
4681
- var import_common10, TagFilteredContentSource;
6753
+ var import_common14, TagFilteredContentSource;
4682
6754
  var init_TagFilteredContentSource = __esm({
4683
6755
  "src/study/TagFilteredContentSource.ts"() {
4684
6756
  "use strict";
4685
- import_common10 = require("@vue-skuilder/common");
6757
+ import_common14 = require("@vue-skuilder/common");
4686
6758
  init_courseDB();
4687
6759
  init_logger();
4688
6760
  TagFilteredContentSource = class {
@@ -4768,7 +6840,7 @@ var init_TagFilteredContentSource = __esm({
4768
6840
  * @returns Cards sorted by score descending (all scores = 1.0)
4769
6841
  */
4770
6842
  async getWeightedCards(limit) {
4771
- if (!(0, import_common10.hasActiveFilter)(this.filter)) {
6843
+ if (!(0, import_common14.hasActiveFilter)(this.filter)) {
4772
6844
  logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
4773
6845
  return [];
4774
6846
  }
@@ -4856,19 +6928,19 @@ async function getStudySource(source, user) {
4856
6928
  if (source.type === "classroom") {
4857
6929
  return await StudentClassroomDB.factory(source.id, user);
4858
6930
  } else {
4859
- if ((0, import_common11.hasActiveFilter)(source.tagFilter)) {
6931
+ if ((0, import_common15.hasActiveFilter)(source.tagFilter)) {
4860
6932
  return new TagFilteredContentSource(source.id, source.tagFilter, user);
4861
6933
  }
4862
6934
  return getDataLayer().getCourseDB(source.id);
4863
6935
  }
4864
6936
  }
4865
- var import_common11;
6937
+ var import_common15;
4866
6938
  var init_contentSource = __esm({
4867
6939
  "src/core/interfaces/contentSource.ts"() {
4868
6940
  "use strict";
4869
6941
  init_factory();
4870
6942
  init_classroomDB2();
4871
- import_common11 = require("@vue-skuilder/common");
6943
+ import_common15 = require("@vue-skuilder/common");
4872
6944
  init_TagFilteredContentSource();
4873
6945
  }
4874
6946
  });
@@ -4931,29 +7003,18 @@ var init_userOutcome = __esm({
4931
7003
  }
4932
7004
  });
4933
7005
 
4934
- // src/core/util/index.ts
4935
- function getCardHistoryID(courseID, cardID) {
4936
- return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
4937
- }
4938
- var init_util = __esm({
4939
- "src/core/util/index.ts"() {
4940
- "use strict";
4941
- init_types_legacy();
4942
- }
4943
- });
4944
-
4945
7006
  // src/core/bulkImport/cardProcessor.ts
4946
- var import_common12;
7007
+ var import_common16;
4947
7008
  var init_cardProcessor = __esm({
4948
7009
  "src/core/bulkImport/cardProcessor.ts"() {
4949
7010
  "use strict";
4950
- import_common12 = require("@vue-skuilder/common");
7011
+ import_common16 = require("@vue-skuilder/common");
4951
7012
  init_logger();
4952
7013
  }
4953
7014
  });
4954
7015
 
4955
7016
  // src/core/bulkImport/types.ts
4956
- var init_types3 = __esm({
7017
+ var init_types5 = __esm({
4957
7018
  "src/core/bulkImport/types.ts"() {
4958
7019
  "use strict";
4959
7020
  }
@@ -4964,29 +7025,7 @@ var init_bulkImport = __esm({
4964
7025
  "src/core/bulkImport/index.ts"() {
4965
7026
  "use strict";
4966
7027
  init_cardProcessor();
4967
- init_types3();
4968
- }
4969
- });
4970
-
4971
- // src/util/dataDirectory.ts
4972
- function getAppDataDirectory() {
4973
- if (ENV.LOCAL_STORAGE_PREFIX) {
4974
- return path.join(os.homedir(), `.tuilder`, ENV.LOCAL_STORAGE_PREFIX);
4975
- } else {
4976
- return path.join(os.homedir(), ".tuilder");
4977
- }
4978
- }
4979
- function getDbPath(dbName) {
4980
- return path.join(getAppDataDirectory(), dbName);
4981
- }
4982
- var path, os;
4983
- var init_dataDirectory = __esm({
4984
- "src/util/dataDirectory.ts"() {
4985
- "use strict";
4986
- path = __toESM(require("path"), 1);
4987
- os = __toESM(require("os"), 1);
4988
- init_logger();
4989
- init_factory();
7028
+ init_types5();
4990
7029
  }
4991
7030
  });
4992
7031
 
@@ -5018,7 +7057,7 @@ function getStartAndEndKeys2(key) {
5018
7057
  };
5019
7058
  }
5020
7059
  function updateGuestAccountExpirationDate(guestDB) {
5021
- const currentTime = import_moment3.default.utc();
7060
+ const currentTime = import_moment5.default.utc();
5022
7061
  const expirationDate = currentTime.add(2, "months").toISOString();
5023
7062
  const expiryDocID2 = "GuestAccountExpirationDate";
5024
7063
  void guestDB.get(expiryDocID2).then((doc) => {
@@ -5043,7 +7082,7 @@ function getLocalUserDB(username) {
5043
7082
  }
5044
7083
  }
5045
7084
  function scheduleCardReviewLocal(userDB, review) {
5046
- const now = import_moment3.default.utc();
7085
+ const now = import_moment5.default.utc();
5047
7086
  logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
5048
7087
  void userDB.put({
5049
7088
  _id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT2),
@@ -5066,11 +7105,11 @@ async function removeScheduledCardReviewLocal(userDB, reviewDocID) {
5066
7105
  ${JSON.stringify(err)}`);
5067
7106
  });
5068
7107
  }
5069
- var import_moment3, REVIEW_TIME_FORMAT2, log2;
7108
+ var import_moment5, REVIEW_TIME_FORMAT2, log2;
5070
7109
  var init_userDBHelpers = __esm({
5071
7110
  "src/impl/common/userDBHelpers.ts"() {
5072
7111
  "use strict";
5073
- import_moment3 = __toESM(require("moment"), 1);
7112
+ import_moment5 = __toESM(require("moment"), 1);
5074
7113
  init_core();
5075
7114
  init_logger();
5076
7115
  init_pouchdb_setup();
@@ -5430,11 +7469,11 @@ var init_core = __esm({
5430
7469
  });
5431
7470
 
5432
7471
  // src/impl/couch/user-course-relDB.ts
5433
- var import_moment4, UsrCrsData;
7472
+ var import_moment6, UsrCrsData;
5434
7473
  var init_user_course_relDB = __esm({
5435
7474
  "src/impl/couch/user-course-relDB.ts"() {
5436
7475
  "use strict";
5437
- import_moment4 = __toESM(require("moment"), 1);
7476
+ import_moment6 = __toESM(require("moment"), 1);
5438
7477
  init_logger();
5439
7478
  UsrCrsData = class {
5440
7479
  user;
@@ -5444,11 +7483,11 @@ var init_user_course_relDB = __esm({
5444
7483
  this._courseId = courseId;
5445
7484
  }
5446
7485
  async getReviewsForcast(daysCount) {
5447
- const time = import_moment4.default.utc().add(daysCount, "days");
7486
+ const time = import_moment6.default.utc().add(daysCount, "days");
5448
7487
  return this.getReviewstoDate(time);
5449
7488
  }
5450
7489
  async getPendingReviews() {
5451
- const now = import_moment4.default.utc();
7490
+ const now = import_moment6.default.utc();
5452
7491
  return this.getReviewstoDate(now);
5453
7492
  }
5454
7493
  async getScheduledReviewCount() {
@@ -5484,7 +7523,7 @@ var init_user_course_relDB = __esm({
5484
7523
  `Fetching ${this.user.getUsername()}'s scheduled reviews for course ${this._courseId}.`
5485
7524
  );
5486
7525
  return allReviews.filter((review) => {
5487
- const reviewTime = import_moment4.default.utc(review.reviewTime);
7526
+ const reviewTime = import_moment6.default.utc(review.reviewTime);
5488
7527
  return targetDate.isAfter(reviewTime);
5489
7528
  });
5490
7529
  }
@@ -5666,14 +7705,14 @@ async function dropUserFromClassroom(user, classID) {
5666
7705
  async function getUserClassrooms(user) {
5667
7706
  return getOrCreateClassroomRegistrationsDoc(user);
5668
7707
  }
5669
- var import_common13, import_moment5, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
7708
+ var import_common17, import_moment7, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
5670
7709
  var init_BaseUserDB = __esm({
5671
7710
  "src/impl/common/BaseUserDB.ts"() {
5672
7711
  "use strict";
5673
7712
  init_core();
5674
7713
  init_util();
5675
- import_common13 = require("@vue-skuilder/common");
5676
- import_moment5 = __toESM(require("moment"), 1);
7714
+ import_common17 = require("@vue-skuilder/common");
7715
+ import_moment7 = __toESM(require("moment"), 1);
5677
7716
  init_types_legacy();
5678
7717
  init_logger();
5679
7718
  init_userDBHelpers();
@@ -5722,7 +7761,7 @@ Currently logged-in as ${this._username}.`
5722
7761
  );
5723
7762
  }
5724
7763
  const result = await this.syncStrategy.createAccount(username, password);
5725
- if (result.status === import_common13.Status.ok) {
7764
+ if (result.status === import_common17.Status.ok) {
5726
7765
  log3(`Account created successfully, updating username to ${username}`);
5727
7766
  this._username = username;
5728
7767
  try {
@@ -5764,7 +7803,7 @@ Currently logged-in as ${this._username}.`
5764
7803
  async resetUserData() {
5765
7804
  if (this.syncStrategy.canAuthenticate()) {
5766
7805
  return {
5767
- status: import_common13.Status.error,
7806
+ status: import_common17.Status.error,
5768
7807
  error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
5769
7808
  };
5770
7809
  }
@@ -5786,11 +7825,11 @@ Currently logged-in as ${this._username}.`
5786
7825
  await localDB.bulkDocs(docsToDelete);
5787
7826
  }
5788
7827
  await this.init();
5789
- return { status: import_common13.Status.ok };
7828
+ return { status: import_common17.Status.ok };
5790
7829
  } catch (error) {
5791
7830
  logger.error("Failed to reset user data:", error);
5792
7831
  return {
5793
- status: import_common13.Status.error,
7832
+ status: import_common17.Status.error,
5794
7833
  error: error instanceof Error ? error.message : "Unknown error during reset"
5795
7834
  };
5796
7835
  }
@@ -5937,7 +7976,7 @@ Currently logged-in as ${this._username}.`
5937
7976
  );
5938
7977
  return reviews.rows.filter((r) => {
5939
7978
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
5940
- const date = import_moment5.default.utc(
7979
+ const date = import_moment7.default.utc(
5941
7980
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
5942
7981
  REVIEW_TIME_FORMAT2
5943
7982
  );
@@ -5950,11 +7989,11 @@ Currently logged-in as ${this._username}.`
5950
7989
  }).map((r) => r.doc);
5951
7990
  }
5952
7991
  async getReviewsForcast(daysCount) {
5953
- const time = import_moment5.default.utc().add(daysCount, "days");
7992
+ const time = import_moment7.default.utc().add(daysCount, "days");
5954
7993
  return this.getReviewstoDate(time);
5955
7994
  }
5956
7995
  async getPendingReviews(course_id) {
5957
- const now = import_moment5.default.utc();
7996
+ const now = import_moment7.default.utc();
5958
7997
  return this.getReviewstoDate(now, course_id);
5959
7998
  }
5960
7999
  async getScheduledReviewCount(course_id) {
@@ -6241,7 +8280,7 @@ Currently logged-in as ${this._username}.`
6241
8280
  */
6242
8281
  async putCardRecord(record) {
6243
8282
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
6244
- record.timeStamp = import_moment5.default.utc(record.timeStamp).toString();
8283
+ record.timeStamp = import_moment7.default.utc(record.timeStamp).toString();
6245
8284
  try {
6246
8285
  const cardHistory = await this.update(
6247
8286
  cardHistoryID,
@@ -6257,7 +8296,7 @@ Currently logged-in as ${this._username}.`
6257
8296
  const ret = {
6258
8297
  ...record2
6259
8298
  };
6260
- ret.timeStamp = import_moment5.default.utc(record2.timeStamp);
8299
+ ret.timeStamp = import_moment7.default.utc(record2.timeStamp);
6261
8300
  return ret;
6262
8301
  });
6263
8302
  return cardHistory;
@@ -6712,8 +8751,20 @@ var init_CourseSyncService = __esm({
6712
8751
  */
6713
8752
  async ensureSynced(courseId, forceEnabled) {
6714
8753
  const existing = this.entries.get(courseId);
6715
- if (existing?.status.state === "ready") {
6716
- return;
8754
+ if (existing?.status.state === "ready" && existing.localDB) {
8755
+ const stale = await this.isLocalEpochStale(courseId, existing.localDB);
8756
+ if (!stale) {
8757
+ return;
8758
+ }
8759
+ logger.info(
8760
+ `[CourseSyncService] Remote DB epoch changed for course ${courseId} \u2014 destroying stale local replica`
8761
+ );
8762
+ try {
8763
+ await existing.localDB.destroy();
8764
+ } catch {
8765
+ }
8766
+ existing.localDB = null;
8767
+ existing.readyPromise = null;
6717
8768
  }
6718
8769
  if (existing?.status.state === "disabled") {
6719
8770
  return;
@@ -6777,7 +8828,15 @@ var init_CourseSyncService = __esm({
6777
8828
  }
6778
8829
  entry.status = { state: "syncing" };
6779
8830
  const localDBName = this.localDBName(courseId);
6780
- const localDB = new pouchdb_setup_default(localDBName);
8831
+ let localDB = new pouchdb_setup_default(localDBName);
8832
+ const stale = await this.isLocalEpochStale(courseId, localDB);
8833
+ if (stale) {
8834
+ logger.info(
8835
+ `[CourseSyncService] Stale local DB detected for course ${courseId} \u2014 destroying before sync`
8836
+ );
8837
+ await localDB.destroy();
8838
+ localDB = new pouchdb_setup_default(localDBName);
8839
+ }
6781
8840
  entry.localDB = localDB;
6782
8841
  const remoteDB = this.getRemoteDB(courseId);
6783
8842
  const syncStart = Date.now();
@@ -6867,6 +8926,33 @@ var init_CourseSyncService = __esm({
6867
8926
  }
6868
8927
  }
6869
8928
  }
8929
+ /**
8930
+ * Check whether the local replica's `db-epoch` doc matches the remote.
8931
+ *
8932
+ * The seed script (and optionally upload-cards) writes a `db-epoch`
8933
+ * document with a numeric timestamp. If the remote epoch differs from
8934
+ * the local copy, the remote DB was recreated (e.g., `yarn db:seed`)
8935
+ * and the local PouchDB is stale.
8936
+ *
8937
+ * Returns `true` if stale (epoch mismatch or remote has epoch but local
8938
+ * doesn't). Returns `false` (not stale) if epochs match, or if the
8939
+ * remote doesn't have an epoch doc at all (backwards compat).
8940
+ */
8941
+ async isLocalEpochStale(courseId, localDB) {
8942
+ try {
8943
+ const remoteDB = this.getRemoteDB(courseId);
8944
+ const remoteEpoch = await remoteDB.get("db-epoch");
8945
+ let localEpoch = null;
8946
+ try {
8947
+ localEpoch = await localDB.get("db-epoch");
8948
+ } catch {
8949
+ return true;
8950
+ }
8951
+ return remoteEpoch.epoch !== localEpoch.epoch;
8952
+ } catch {
8953
+ return false;
8954
+ }
8955
+ }
6870
8956
  /**
6871
8957
  * Get a remote PouchDB handle for a course.
6872
8958
  */
@@ -6926,14 +9012,14 @@ var init_auth = __esm({
6926
9012
  });
6927
9013
 
6928
9014
  // src/impl/couch/CouchDBSyncStrategy.ts
6929
- var import_common15, log4, CouchDBSyncStrategy;
9015
+ var import_common19, log4, CouchDBSyncStrategy;
6930
9016
  var init_CouchDBSyncStrategy = __esm({
6931
9017
  "src/impl/couch/CouchDBSyncStrategy.ts"() {
6932
9018
  "use strict";
6933
9019
  init_factory();
6934
9020
  init_types_legacy();
6935
9021
  init_logger();
6936
- import_common15 = require("@vue-skuilder/common");
9022
+ import_common19 = require("@vue-skuilder/common");
6937
9023
  init_common();
6938
9024
  init_pouchdb_setup();
6939
9025
  init_couch();
@@ -7004,32 +9090,32 @@ var init_CouchDBSyncStrategy = __esm({
7004
9090
  }
7005
9091
  }
7006
9092
  return {
7007
- status: import_common15.Status.ok,
9093
+ status: import_common19.Status.ok,
7008
9094
  error: void 0
7009
9095
  };
7010
9096
  } else {
7011
9097
  return {
7012
- status: import_common15.Status.error,
9098
+ status: import_common19.Status.error,
7013
9099
  error: "Failed to log in after account creation"
7014
9100
  };
7015
9101
  }
7016
9102
  } else {
7017
9103
  logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
7018
9104
  return {
7019
- status: import_common15.Status.error,
9105
+ status: import_common19.Status.error,
7020
9106
  error: "Account creation failed"
7021
9107
  };
7022
9108
  }
7023
9109
  } catch (e) {
7024
9110
  if (e.reason === "Document update conflict.") {
7025
9111
  return {
7026
- status: import_common15.Status.error,
9112
+ status: import_common19.Status.error,
7027
9113
  error: "This username is taken!"
7028
9114
  };
7029
9115
  }
7030
9116
  logger.error(`Error on signup: ${JSON.stringify(e)}`);
7031
9117
  return {
7032
- status: import_common15.Status.error,
9118
+ status: import_common19.Status.error,
7033
9119
  error: e.message || "Unknown error during account creation"
7034
9120
  };
7035
9121
  }
@@ -7269,7 +9355,7 @@ async function usernameIsAvailable(username) {
7269
9355
  }
7270
9356
  }
7271
9357
  function updateGuestAccountExpirationDate2(guestDB) {
7272
- const currentTime = import_moment6.default.utc();
9358
+ const currentTime = import_moment8.default.utc();
7273
9359
  const expirationDate = currentTime.add(2, "months").toISOString();
7274
9360
  void guestDB.get(expiryDocID).then((doc) => {
7275
9361
  return guestDB.put({
@@ -7332,7 +9418,7 @@ function getCouchUserDB(username) {
7332
9418
  return ret;
7333
9419
  }
7334
9420
  function scheduleCardReview(review) {
7335
- const now = import_moment6.default.utc();
9421
+ const now = import_moment8.default.utc();
7336
9422
  logger.info(`Scheduling for review in: ${review.time.diff(now, "h") / 24} days`);
7337
9423
  void getCouchUserDB(review.user).put({
7338
9424
  _id: DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */] + review.time.format(REVIEW_TIME_FORMAT),
@@ -7361,13 +9447,13 @@ function getStartAndEndKeys(key) {
7361
9447
  endkey: key + "\uFFF0"
7362
9448
  };
7363
9449
  }
7364
- var import_cross_fetch2, import_moment6, import_process, isBrowser, expiryDocID, GUEST_LOCAL_DB, localUserDB, pouchDBincludeCredentialsConfig, REVIEW_TIME_FORMAT;
9450
+ var import_cross_fetch2, import_moment8, import_process, isBrowser, expiryDocID, GUEST_LOCAL_DB, localUserDB, pouchDBincludeCredentialsConfig, REVIEW_TIME_FORMAT;
7365
9451
  var init_couch = __esm({
7366
9452
  "src/impl/couch/index.ts"() {
7367
9453
  init_factory();
7368
9454
  init_types_legacy();
7369
9455
  import_cross_fetch2 = __toESM(require("cross-fetch"), 1);
7370
- import_moment6 = __toESM(require("moment"), 1);
9456
+ import_moment8 = __toESM(require("moment"), 1);
7371
9457
  init_logger();
7372
9458
  init_pouchdb_setup();
7373
9459
  import_process = __toESM(require("process"), 1);