@vue-skuilder/db 0.1.31-b → 0.1.31

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 (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
@@ -649,8 +649,12 @@ __export(PipelineDebugger_exports, {
649
649
  buildRunReport: () => buildRunReport,
650
650
  captureRun: () => captureRun,
651
651
  mountPipelineDebugger: () => mountPipelineDebugger,
652
- pipelineDebugAPI: () => pipelineDebugAPI
652
+ pipelineDebugAPI: () => pipelineDebugAPI,
653
+ registerPipelineForDebug: () => registerPipelineForDebug
653
654
  });
655
+ function registerPipelineForDebug(pipeline) {
656
+ _activePipeline = pipeline;
657
+ }
654
658
  function getOrigin(card) {
655
659
  const firstEntry = card.provenance[0];
656
660
  if (!firstEntry) return "unknown";
@@ -678,6 +682,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
678
682
  origin: getOrigin(card),
679
683
  finalScore: card.score,
680
684
  provenance: card.provenance,
685
+ tags: card.tags,
681
686
  selected: selectedIds.has(card.cardId)
682
687
  }));
683
688
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -733,12 +738,13 @@ function mountPipelineDebugger() {
733
738
  win.skuilder = win.skuilder || {};
734
739
  win.skuilder.pipeline = pipelineDebugAPI;
735
740
  }
736
- var MAX_RUNS, runHistory, pipelineDebugAPI;
741
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
737
742
  var init_PipelineDebugger = __esm({
738
743
  "src/core/navigators/PipelineDebugger.ts"() {
739
744
  "use strict";
740
745
  init_navigators();
741
746
  init_logger();
747
+ _activePipeline = null;
742
748
  MAX_RUNS = 10;
743
749
  runHistory = [];
744
750
  pipelineDebugAPI = {
@@ -940,6 +946,21 @@ var init_PipelineDebugger = __esm({
940
946
  }
941
947
  console.groupEnd();
942
948
  },
949
+ /**
950
+ * Scan the full card space through the filter chain for the current user.
951
+ *
952
+ * Reports how many cards are well-indicated and how many are new.
953
+ * Use this to understand how the search space grows during onboarding.
954
+ *
955
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
956
+ */
957
+ async diagnoseCardSpace(threshold) {
958
+ if (!_activePipeline) {
959
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
960
+ return null;
961
+ }
962
+ return _activePipeline.diagnoseCardSpace({ threshold });
963
+ },
943
964
  /**
944
965
  * Show help.
945
966
  */
@@ -952,6 +973,7 @@ Commands:
952
973
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
953
974
  .showCard(cardId) Show provenance trail for a specific card
954
975
  .explainReviews() Analyze why reviews were/weren't selected
976
+ .diagnoseCardSpace() Scan full card space through filters (async)
955
977
  .showRegistry() Show navigator registry (classes + roles)
956
978
  .showStrategies() Show registry + strategy mapping from last run
957
979
  .listRuns() List all captured runs in table format
@@ -963,7 +985,7 @@ Commands:
963
985
  Example:
964
986
  window.skuilder.pipeline.showLastRun()
965
987
  window.skuilder.pipeline.showRun(1)
966
- window.skuilder.pipeline.showCard('abc123')
988
+ await window.skuilder.pipeline.diagnoseCardSpace()
967
989
  `);
968
990
  }
969
991
  };
@@ -1258,6 +1280,69 @@ var init_generators = __esm({
1258
1280
  }
1259
1281
  });
1260
1282
 
1283
+ // src/core/navigators/generators/prescribed.ts
1284
+ var prescribed_exports = {};
1285
+ __export(prescribed_exports, {
1286
+ default: () => PrescribedCardsGenerator
1287
+ });
1288
+ var PrescribedCardsGenerator;
1289
+ var init_prescribed = __esm({
1290
+ "src/core/navigators/generators/prescribed.ts"() {
1291
+ "use strict";
1292
+ init_navigators();
1293
+ init_logger();
1294
+ PrescribedCardsGenerator = class extends ContentNavigator {
1295
+ name;
1296
+ config;
1297
+ constructor(user, course, strategyData) {
1298
+ super(user, course, strategyData);
1299
+ 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
+ }
1306
+ logger.debug(
1307
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1308
+ );
1309
+ }
1310
+ async getWeightedCards(limit, _context) {
1311
+ if (this.config.cardIds.length === 0) {
1312
+ return [];
1313
+ }
1314
+ const courseId = this.course.getCourseID();
1315
+ const activeCards = await this.user.getActiveCards();
1316
+ 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");
1320
+ return [];
1321
+ }
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)`
1334
+ }
1335
+ ]
1336
+ }));
1337
+ logger.info(
1338
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1339
+ );
1340
+ return cards;
1341
+ }
1342
+ };
1343
+ }
1344
+ });
1345
+
1261
1346
  // src/core/navigators/generators/srs.ts
1262
1347
  var srs_exports = {};
1263
1348
  __export(srs_exports, {
@@ -1452,6 +1537,7 @@ var init_ = __esm({
1452
1537
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1453
1538
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1454
1539
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1540
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1455
1541
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1456
1542
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1457
1543
  });
@@ -1652,6 +1738,8 @@ var init_hierarchyDefinition = __esm({
1652
1738
  if (userTagElo.count < minCount) return false;
1653
1739
  if (prereq.masteryThreshold?.minElo !== void 0) {
1654
1740
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1741
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1742
+ return true;
1655
1743
  } else {
1656
1744
  return userTagElo.score >= userGlobalElo;
1657
1745
  }
@@ -1727,14 +1815,38 @@ var init_hierarchyDefinition = __esm({
1727
1815
  };
1728
1816
  }
1729
1817
  }
1818
+ /**
1819
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1820
+ *
1821
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1822
+ * tags get boosted — steering the pipeline toward content that helps unlock
1823
+ * the gated material. Once the gate opens, the boost disappears.
1824
+ */
1825
+ getPreReqBoosts(unlockedTags, masteredTags) {
1826
+ const boosts = /* @__PURE__ */ new Map();
1827
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1828
+ if (unlockedTags.has(tagId)) continue;
1829
+ for (const prereq of prereqs) {
1830
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1831
+ if (masteredTags.has(prereq.tag)) continue;
1832
+ const existing = boosts.get(prereq.tag) ?? 1;
1833
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1834
+ }
1835
+ }
1836
+ return boosts;
1837
+ }
1730
1838
  /**
1731
1839
  * CardFilter.transform implementation.
1732
1840
  *
1733
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1841
+ * Two effects:
1842
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1843
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1844
+ * boost (preReqBoost), steering toward content that unlocks gates
1734
1845
  */
1735
1846
  async transform(cards, context) {
1736
1847
  const masteredTags = await this.getMasteredTags(context);
1737
1848
  const unlockedTags = this.getUnlockedTags(masteredTags);
1849
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1738
1850
  const gated = [];
1739
1851
  for (const card of cards) {
1740
1852
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1743,9 +1855,27 @@ var init_hierarchyDefinition = __esm({
1743
1855
  unlockedTags,
1744
1856
  masteredTags
1745
1857
  );
1746
- const LOCKED_PENALTY = 0.01;
1747
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1748
- const action = isUnlocked ? "passed" : "penalized";
1858
+ const LOCKED_PENALTY = 0.02;
1859
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1860
+ let action = isUnlocked ? "passed" : "penalized";
1861
+ let finalReason = reason;
1862
+ if (isUnlocked && preReqBoosts.size > 0) {
1863
+ const cardTags = card.tags ?? [];
1864
+ let maxBoost = 1;
1865
+ const boostedPrereqs = [];
1866
+ for (const tag of cardTags) {
1867
+ const boost = preReqBoosts.get(tag);
1868
+ if (boost && boost > maxBoost) {
1869
+ maxBoost = boost;
1870
+ boostedPrereqs.push(tag);
1871
+ }
1872
+ }
1873
+ if (maxBoost > 1) {
1874
+ finalScore *= maxBoost;
1875
+ action = "boosted";
1876
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1877
+ }
1878
+ }
1749
1879
  gated.push({
1750
1880
  ...card,
1751
1881
  score: finalScore,
@@ -1757,7 +1887,7 @@ var init_hierarchyDefinition = __esm({
1757
1887
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1758
1888
  action,
1759
1889
  score: finalScore,
1760
- reason
1890
+ reason: finalReason
1761
1891
  }
1762
1892
  ]
1763
1893
  });
@@ -2445,6 +2575,18 @@ var Pipeline_exports = {};
2445
2575
  __export(Pipeline_exports, {
2446
2576
  Pipeline: () => Pipeline
2447
2577
  });
2578
+ function globToRegex(pattern) {
2579
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2580
+ const withWildcards = escaped.replace(/\*/g, ".*");
2581
+ return new RegExp(`^${withWildcards}$`);
2582
+ }
2583
+ function globMatch(value, pattern) {
2584
+ if (!pattern.includes("*")) return value === pattern;
2585
+ return globToRegex(pattern).test(value);
2586
+ }
2587
+ function cardMatchesTagPattern(card, pattern) {
2588
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2589
+ }
2448
2590
  function logPipelineConfig(generator, filters) {
2449
2591
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2450
2592
  logger.info(
@@ -2479,6 +2621,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2479
2621
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2480
2622
  );
2481
2623
  }
2624
+ function logResultCards(cards) {
2625
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2626
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2627
+ for (let i = 0; i < cards.length; i++) {
2628
+ const c = cards[i];
2629
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2630
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2631
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2632
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2633
+ }).join(" | ");
2634
+ logger.info(
2635
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2636
+ );
2637
+ }
2638
+ }
2482
2639
  function logCardProvenance(cards, maxCards = 3) {
2483
2640
  const cardsToLog = cards.slice(0, maxCards);
2484
2641
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2493,7 +2650,7 @@ function logCardProvenance(cards, maxCards = 3) {
2493
2650
  }
2494
2651
  }
2495
2652
  }
2496
- var import_common8, Pipeline;
2653
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2497
2654
  var init_Pipeline = __esm({
2498
2655
  "src/core/navigators/Pipeline.ts"() {
2499
2656
  "use strict";
@@ -2502,9 +2659,31 @@ var init_Pipeline = __esm({
2502
2659
  init_logger();
2503
2660
  init_orchestration();
2504
2661
  init_PipelineDebugger();
2662
+ VERBOSE_RESULTS = true;
2505
2663
  Pipeline = class extends ContentNavigator {
2506
2664
  generator;
2507
2665
  filters;
2666
+ /**
2667
+ * Cached orchestration context. Course config and salt don't change within
2668
+ * a page load, so we build the orchestration context once and reuse it on
2669
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2670
+ *
2671
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2672
+ */
2673
+ _cachedOrchestration = null;
2674
+ /**
2675
+ * Persistent tag cache. Maps cardId → tag names.
2676
+ *
2677
+ * Tags are static within a session (they're set at card generation time),
2678
+ * so we cache them across pipeline runs. On replans, many of the same cards
2679
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2680
+ */
2681
+ _tagCache = /* @__PURE__ */ new Map();
2682
+ /**
2683
+ * One-shot replan hints. Applied after the filter chain on the next
2684
+ * getWeightedCards() call, then cleared.
2685
+ */
2686
+ _ephemeralHints = null;
2508
2687
  /**
2509
2688
  * Create a new pipeline.
2510
2689
  *
@@ -2525,6 +2704,17 @@ var init_Pipeline = __esm({
2525
2704
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2526
2705
  });
2527
2706
  logPipelineConfig(generator, filters);
2707
+ registerPipelineForDebug(this);
2708
+ }
2709
+ /**
2710
+ * Set one-shot hints for the next pipeline run.
2711
+ * Consumed after one getWeightedCards() call, then cleared.
2712
+ *
2713
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
2714
+ */
2715
+ setEphemeralHints(hints) {
2716
+ this._ephemeralHints = hints;
2717
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2528
2718
  }
2529
2719
  /**
2530
2720
  * Get weighted cards by running generator and applying filters.
@@ -2541,13 +2731,15 @@ var init_Pipeline = __esm({
2541
2731
  * @returns Cards sorted by score descending
2542
2732
  */
2543
2733
  async getWeightedCards(limit) {
2734
+ const t0 = performance.now();
2544
2735
  const context = await this.buildContext();
2545
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2546
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2736
+ const tContext = performance.now();
2737
+ const fetchLimit = 500;
2547
2738
  logger.debug(
2548
2739
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2549
2740
  );
2550
2741
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2742
+ const tGenerate = performance.now();
2551
2743
  const generatedCount = cards.length;
2552
2744
  let generatorSummaries;
2553
2745
  if (this.generator.generators) {
@@ -2576,6 +2768,7 @@ var init_Pipeline = __esm({
2576
2768
  }
2577
2769
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2578
2770
  cards = await this.hydrateTags(cards);
2771
+ const tHydrate = performance.now();
2579
2772
  const allCardsBeforeFiltering = [...cards];
2580
2773
  const filterImpacts = [];
2581
2774
  for (const filter of this.filters) {
@@ -2594,8 +2787,17 @@ var init_Pipeline = __esm({
2594
2787
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2595
2788
  }
2596
2789
  cards = cards.filter((c) => c.score > 0);
2790
+ const hints = this._ephemeralHints;
2791
+ if (hints) {
2792
+ this._ephemeralHints = null;
2793
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
2794
+ }
2597
2795
  cards.sort((a, b) => b.score - a.score);
2796
+ const tFilter = performance.now();
2598
2797
  const result = cards.slice(0, limit);
2798
+ logger.info(
2799
+ `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms (context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
2800
+ );
2599
2801
  const topScores = result.slice(0, 3).map((c) => c.score);
2600
2802
  logExecutionSummary(
2601
2803
  this.generator.name,
@@ -2605,6 +2807,7 @@ var init_Pipeline = __esm({
2605
2807
  topScores,
2606
2808
  filterImpacts
2607
2809
  );
2810
+ logResultCards(result);
2608
2811
  logCardProvenance(result, 3);
2609
2812
  try {
2610
2813
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2631,6 +2834,10 @@ var init_Pipeline = __esm({
2631
2834
  * to the WeightedCard objects. Filters can then use card.tags instead of
2632
2835
  * making individual getAppliedTags() calls.
2633
2836
  *
2837
+ * Uses a persistent tag cache across pipeline runs — tags are static within
2838
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
2839
+ * require a second DB query.
2840
+ *
2634
2841
  * @param cards - Cards to hydrate
2635
2842
  * @returns Cards with tags populated
2636
2843
  */
@@ -2638,14 +2845,128 @@ var init_Pipeline = __esm({
2638
2845
  if (cards.length === 0) {
2639
2846
  return cards;
2640
2847
  }
2641
- const cardIds = cards.map((c) => c.cardId);
2642
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2848
+ const uncachedIds = [];
2849
+ for (const card of cards) {
2850
+ if (!this._tagCache.has(card.cardId)) {
2851
+ uncachedIds.push(card.cardId);
2852
+ }
2853
+ }
2854
+ if (uncachedIds.length > 0) {
2855
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
2856
+ for (const [cardId, tags] of freshTags) {
2857
+ this._tagCache.set(cardId, tags);
2858
+ }
2859
+ }
2860
+ const tagsByCard = /* @__PURE__ */ new Map();
2861
+ for (const card of cards) {
2862
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
2863
+ }
2643
2864
  logTagHydration(cards, tagsByCard);
2644
2865
  return cards.map((card) => ({
2645
2866
  ...card,
2646
- tags: tagsByCard.get(card.cardId) ?? []
2867
+ tags: this._tagCache.get(card.cardId) ?? []
2647
2868
  }));
2648
2869
  }
2870
+ // ---------------------------------------------------------------------------
2871
+ // Ephemeral hints application
2872
+ // ---------------------------------------------------------------------------
2873
+ /**
2874
+ * Apply one-shot replan hints to the post-filter card set.
2875
+ *
2876
+ * Order of operations:
2877
+ * 1. Exclude (remove unwanted cards)
2878
+ * 2. Boost (multiply scores)
2879
+ * 3. Require (inject must-have cards from the full pre-filter pool)
2880
+ *
2881
+ * @param cards - Post-filter cards (score > 0)
2882
+ * @param hints - The ephemeral hints to apply
2883
+ * @param allCards - Full pre-filter card pool (for require injection)
2884
+ */
2885
+ applyHints(cards, hints, allCards) {
2886
+ const beforeCount = cards.length;
2887
+ if (hints.excludeCards?.length) {
2888
+ cards = cards.filter(
2889
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
2890
+ );
2891
+ }
2892
+ if (hints.excludeTags?.length) {
2893
+ cards = cards.filter(
2894
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
2895
+ );
2896
+ }
2897
+ if (hints.boostTags) {
2898
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
2899
+ for (const card of cards) {
2900
+ if (cardMatchesTagPattern(card, pattern)) {
2901
+ card.score *= factor;
2902
+ card.provenance.push({
2903
+ strategy: "ephemeralHint",
2904
+ strategyId: "ephemeral-hint",
2905
+ strategyName: "Replan Hint",
2906
+ action: "boosted",
2907
+ score: card.score,
2908
+ reason: `boostTag ${pattern} \xD7${factor}`
2909
+ });
2910
+ }
2911
+ }
2912
+ }
2913
+ }
2914
+ if (hints.boostCards) {
2915
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
2916
+ for (const card of cards) {
2917
+ if (globMatch(card.cardId, pattern)) {
2918
+ card.score *= factor;
2919
+ card.provenance.push({
2920
+ strategy: "ephemeralHint",
2921
+ strategyId: "ephemeral-hint",
2922
+ strategyName: "Replan Hint",
2923
+ action: "boosted",
2924
+ score: card.score,
2925
+ reason: `boostCard ${pattern} \xD7${factor}`
2926
+ });
2927
+ }
2928
+ }
2929
+ }
2930
+ }
2931
+ const cardIds = new Set(cards.map((c) => c.cardId));
2932
+ const inject = (card, reason) => {
2933
+ if (!cardIds.has(card.cardId)) {
2934
+ const floorScore = Math.max(card.score, 1);
2935
+ cards.push({
2936
+ ...card,
2937
+ score: floorScore,
2938
+ provenance: [
2939
+ ...card.provenance,
2940
+ {
2941
+ strategy: "ephemeralHint",
2942
+ strategyId: "ephemeral-hint",
2943
+ strategyName: "Replan Hint",
2944
+ action: "boosted",
2945
+ score: floorScore,
2946
+ reason
2947
+ }
2948
+ ]
2949
+ });
2950
+ cardIds.add(card.cardId);
2951
+ }
2952
+ };
2953
+ if (hints.requireCards?.length) {
2954
+ for (const pattern of hints.requireCards) {
2955
+ for (const card of allCards) {
2956
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
2957
+ }
2958
+ }
2959
+ }
2960
+ if (hints.requireTags?.length) {
2961
+ for (const pattern of hints.requireTags) {
2962
+ for (const card of allCards) {
2963
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
2964
+ }
2965
+ }
2966
+ }
2967
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
2968
+ return cards;
2969
+ }
2649
2970
  /**
2650
2971
  * Build shared context for generator and filters.
2651
2972
  *
@@ -2663,7 +2984,10 @@ var init_Pipeline = __esm({
2663
2984
  } catch (e) {
2664
2985
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2665
2986
  }
2666
- const orchestration = await createOrchestrationContext(this.user, this.course);
2987
+ if (!this._cachedOrchestration) {
2988
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
2989
+ }
2990
+ const orchestration = this._cachedOrchestration;
2667
2991
  return {
2668
2992
  user: this.user,
2669
2993
  course: this.course,
@@ -2707,6 +3031,87 @@ var init_Pipeline = __esm({
2707
3031
  }
2708
3032
  return [...new Set(ids)];
2709
3033
  }
3034
+ // ---------------------------------------------------------------------------
3035
+ // Card-space diagnostic
3036
+ // ---------------------------------------------------------------------------
3037
+ /**
3038
+ * Scan every card in the course through the filter chain and report
3039
+ * how many are "well indicated" (score >= threshold) for the current user.
3040
+ *
3041
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3042
+ *
3043
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3044
+ */
3045
+ async diagnoseCardSpace(opts) {
3046
+ const THRESHOLD = opts?.threshold ?? 0.1;
3047
+ const t0 = performance.now();
3048
+ const allCardIds = await this.course.getAllCardIds();
3049
+ let cards = allCardIds.map((cardId) => ({
3050
+ cardId,
3051
+ courseId: this.course.getCourseID(),
3052
+ score: 1,
3053
+ provenance: []
3054
+ }));
3055
+ cards = await this.hydrateTags(cards);
3056
+ const context = await this.buildContext();
3057
+ const filterBreakdown = [];
3058
+ for (const filter of this.filters) {
3059
+ cards = await filter.transform(cards, context);
3060
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3061
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3062
+ }
3063
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3064
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3065
+ let encounteredIds;
3066
+ try {
3067
+ const courseId = this.course.getCourseID();
3068
+ const seenCards = await this.user.getSeenCards(courseId);
3069
+ encounteredIds = new Set(seenCards);
3070
+ } catch {
3071
+ encounteredIds = /* @__PURE__ */ new Set();
3072
+ }
3073
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3074
+ const byType = /* @__PURE__ */ new Map();
3075
+ for (const card of cards) {
3076
+ const type = card.cardId.split("-")[1] || "unknown";
3077
+ if (!byType.has(type)) {
3078
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3079
+ }
3080
+ const entry = byType.get(type);
3081
+ entry.total++;
3082
+ if (card.score >= THRESHOLD) {
3083
+ entry.wellIndicated++;
3084
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3085
+ }
3086
+ }
3087
+ const elapsed = performance.now() - t0;
3088
+ const result = {
3089
+ totalCards: allCardIds.length,
3090
+ threshold: THRESHOLD,
3091
+ wellIndicated: wellIndicatedIds.size,
3092
+ encountered: encounteredIds.size,
3093
+ wellIndicatedNew: wellIndicatedNew.length,
3094
+ byType: Object.fromEntries(byType),
3095
+ filterBreakdown,
3096
+ elapsedMs: Math.round(elapsed)
3097
+ };
3098
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3099
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3100
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3101
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3102
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3103
+ logger.info(`[Pipeline:diagnose] By type:`);
3104
+ for (const [type, counts] of byType) {
3105
+ logger.info(
3106
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3107
+ );
3108
+ }
3109
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3110
+ for (const fb of filterBreakdown) {
3111
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3112
+ }
3113
+ return result;
3114
+ }
2710
3115
  };
2711
3116
  }
2712
3117
  });
@@ -2811,23 +3216,25 @@ var init_PipelineAssembler = __esm({
2811
3216
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2812
3217
  }
2813
3218
  }
3219
+ const courseId = course.getCourseID();
3220
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3221
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3222
+ if (!hasElo) {
3223
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3224
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3225
+ }
3226
+ if (!hasSrs) {
3227
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3228
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3229
+ }
2814
3230
  if (generatorStrategies.length === 0) {
2815
- if (filterStrategies.length > 0) {
2816
- logger.debug(
2817
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2818
- );
2819
- const courseId = course.getCourseID();
2820
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2821
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2822
- } else {
2823
- warnings.push("No generator strategy found");
2824
- return {
2825
- pipeline: null,
2826
- generatorStrategies: [],
2827
- filterStrategies: [],
2828
- warnings
2829
- };
2830
- }
3231
+ warnings.push("No generator strategy found");
3232
+ return {
3233
+ pipeline: null,
3234
+ generatorStrategies: [],
3235
+ filterStrategies: [],
3236
+ warnings
3237
+ };
2831
3238
  }
2832
3239
  let generator;
2833
3240
  if (generatorStrategies.length === 1) {
@@ -2905,6 +3312,7 @@ var init_3 = __esm({
2905
3312
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2906
3313
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2907
3314
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3315
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2908
3316
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2909
3317
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2910
3318
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2953,8 +3361,10 @@ async function initializeNavigatorRegistry() {
2953
3361
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2954
3362
  Promise.resolve().then(() => (init_srs(), srs_exports))
2955
3363
  ]);
3364
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2956
3365
  registerNavigator("elo", eloModule.default);
2957
3366
  registerNavigator("srs", srsModule.default);
3367
+ registerNavigator("prescribed", prescribedModule.default);
2958
3368
  const [
2959
3369
  hierarchyModule,
2960
3370
  interferenceModule,
@@ -3009,6 +3419,7 @@ var init_navigators = __esm({
3009
3419
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3010
3420
  Navigators2["ELO"] = "elo";
3011
3421
  Navigators2["SRS"] = "srs";
3422
+ Navigators2["PRESCRIBED"] = "prescribed";
3012
3423
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3013
3424
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3014
3425
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3023,6 +3434,7 @@ var init_navigators = __esm({
3023
3434
  NavigatorRoles = {
3024
3435
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3025
3436
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3437
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3026
3438
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3027
3439
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3028
3440
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3187,6 +3599,12 @@ var init_navigators = __esm({
3187
3599
  async getWeightedCards(_limit) {
3188
3600
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3189
3601
  }
3602
+ /**
3603
+ * Set ephemeral hints for the next pipeline run.
3604
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3605
+ */
3606
+ setEphemeralHints(_hints) {
3607
+ }
3190
3608
  };
3191
3609
  }
3192
3610
  });
@@ -3373,15 +3791,42 @@ var init_courseDB = __esm({
3373
3791
  // private log(msg: string): void {
3374
3792
  // log(`CourseLog: ${this.id}\n ${msg}`);
3375
3793
  // }
3794
+ /**
3795
+ * Primary database handle used for all **read** operations (queries, gets).
3796
+ *
3797
+ * When local sync is active, this points to the local PouchDB replica for
3798
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
3799
+ */
3376
3800
  db;
3801
+ /**
3802
+ * Remote database handle used for all **write** operations.
3803
+ *
3804
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
3805
+ * mutations, admin operations) aggregate on the server. The local replica
3806
+ * is a read-only snapshot that refreshes on the next page load.
3807
+ *
3808
+ * When local sync is NOT active, this is the same instance as `this.db`.
3809
+ */
3810
+ remoteDB;
3377
3811
  id;
3378
3812
  _getCurrentUser;
3379
3813
  updateQueue;
3380
- constructor(id, userLookup) {
3814
+ /**
3815
+ * @param id - Course ID
3816
+ * @param userLookup - Async function returning the current user DB
3817
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
3818
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
3819
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
3820
+ * values may be stale, so read-modify-write cycles must go through
3821
+ * the remote DB to avoid conflicts).
3822
+ */
3823
+ constructor(id, userLookup, localDB) {
3381
3824
  this.id = id;
3382
- this.db = getCourseDB2(this.id);
3825
+ const remote = getCourseDB2(this.id);
3826
+ this.remoteDB = remote;
3827
+ this.db = localDB ?? remote;
3383
3828
  this._getCurrentUser = userLookup;
3384
- this.updateQueue = new UpdateQueue(this.db);
3829
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3385
3830
  }
3386
3831
  getCourseID() {
3387
3832
  return this.id;
@@ -3469,7 +3914,7 @@ var init_courseDB = __esm({
3469
3914
  };
3470
3915
  }
3471
3916
  async removeCard(id) {
3472
- const doc = await this.db.get(id);
3917
+ const doc = await this.remoteDB.get(id);
3473
3918
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3474
3919
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3475
3920
  }
@@ -3490,7 +3935,7 @@ var init_courseDB = __esm({
3490
3935
  } catch (error) {
3491
3936
  logger.error(`Error removing card ${id} from tags: ${error}`);
3492
3937
  }
3493
- return this.db.remove(doc);
3938
+ return this.remoteDB.remove(doc);
3494
3939
  }
3495
3940
  async getCardDisplayableDataIDs(id) {
3496
3941
  logger.debug(id.join(", "));
@@ -3592,8 +4037,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3592
4037
  if (cardIds.length === 0) {
3593
4038
  return /* @__PURE__ */ new Map();
3594
4039
  }
3595
- const db = getCourseDB2(this.id);
3596
- const result = await db.query("getTags", {
4040
+ const result = await this.db.query("getTags", {
3597
4041
  keys: cardIds,
3598
4042
  include_docs: false
3599
4043
  });
@@ -3610,6 +4054,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3610
4054
  }
3611
4055
  return tagsByCard;
3612
4056
  }
4057
+ async getAllCardIds() {
4058
+ const result = await this.db.allDocs({
4059
+ startkey: "CARD-",
4060
+ endkey: "CARD-\uFFF0",
4061
+ include_docs: false
4062
+ });
4063
+ return result.rows.map((row) => row.id);
4064
+ }
3613
4065
  async addTagToCard(cardId, tagId, updateELO) {
3614
4066
  return await addTagToCard(
3615
4067
  this.id,
@@ -3676,10 +4128,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3676
4128
  }
3677
4129
  }
3678
4130
  async getCourseDoc(id, options) {
3679
- return await getCourseDoc(this.id, id, options);
4131
+ return await this.db.get(id, options);
3680
4132
  }
3681
4133
  async getCourseDocs(ids, options = {}) {
3682
- return await getCourseDocs(this.id, ids, options);
4134
+ return await this.db.allDocs({
4135
+ ...options,
4136
+ keys: ids
4137
+ });
3683
4138
  }
3684
4139
  ////////////////////////////////////
3685
4140
  // NavigationStrategyManager implementation
@@ -3713,7 +4168,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3713
4168
  }
3714
4169
  async addNavigationStrategy(data) {
3715
4170
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3716
- return this.db.put(data).then(() => {
4171
+ return this.remoteDB.put(data).then(() => {
3717
4172
  });
3718
4173
  }
3719
4174
  updateNavigationStrategy(id, data) {
@@ -5307,6 +5762,9 @@ Currently logged-in as ${this._username}.`
5307
5762
  const id = row.id;
5308
5763
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5309
5764
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
5765
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
5766
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
5767
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5310
5768
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5311
5769
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5312
5770
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6184,6 +6642,234 @@ var init_adminDB2 = __esm({
6184
6642
  }
6185
6643
  });
6186
6644
 
6645
+ // src/impl/couch/CourseSyncService.ts
6646
+ var CourseSyncService;
6647
+ var init_CourseSyncService = __esm({
6648
+ "src/impl/couch/CourseSyncService.ts"() {
6649
+ "use strict";
6650
+ init_pouchdb_setup();
6651
+ init_couch();
6652
+ init_logger();
6653
+ CourseSyncService = class _CourseSyncService {
6654
+ static instance = null;
6655
+ entries = /* @__PURE__ */ new Map();
6656
+ constructor() {
6657
+ }
6658
+ static getInstance() {
6659
+ if (!_CourseSyncService.instance) {
6660
+ _CourseSyncService.instance = new _CourseSyncService();
6661
+ }
6662
+ return _CourseSyncService.instance;
6663
+ }
6664
+ /**
6665
+ * Reset the singleton (for testing).
6666
+ */
6667
+ static resetInstance() {
6668
+ if (_CourseSyncService.instance) {
6669
+ for (const [, entry] of _CourseSyncService.instance.entries) {
6670
+ if (entry.localDB) {
6671
+ entry.localDB.close().catch(() => {
6672
+ });
6673
+ }
6674
+ }
6675
+ _CourseSyncService.instance.entries.clear();
6676
+ }
6677
+ _CourseSyncService.instance = null;
6678
+ }
6679
+ // --------------------------------------------------------------------------
6680
+ // Public API
6681
+ // --------------------------------------------------------------------------
6682
+ /**
6683
+ * Ensure a course's local replica is synced.
6684
+ *
6685
+ * On first call for a course:
6686
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
6687
+ * 2. If enabled, performs one-shot replication remote → local
6688
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
6689
+ *
6690
+ * On subsequent calls: returns immediately if already synced, or awaits
6691
+ * the in-flight sync if one is in progress.
6692
+ *
6693
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
6694
+ *
6695
+ * @param courseId - The course to sync
6696
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
6697
+ * Useful when the caller already knows local sync is desired (e.g.,
6698
+ * LettersPractice hardcodes this).
6699
+ */
6700
+ async ensureSynced(courseId, forceEnabled) {
6701
+ const existing = this.entries.get(courseId);
6702
+ if (existing?.status.state === "ready") {
6703
+ return;
6704
+ }
6705
+ if (existing?.status.state === "disabled") {
6706
+ return;
6707
+ }
6708
+ if (existing?.readyPromise) {
6709
+ return existing.readyPromise;
6710
+ }
6711
+ const entry = {
6712
+ localDB: null,
6713
+ status: { state: "not-started" },
6714
+ readyPromise: null
6715
+ };
6716
+ this.entries.set(courseId, entry);
6717
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
6718
+ return entry.readyPromise;
6719
+ }
6720
+ /**
6721
+ * Get the local PouchDB for a course, or null if not available.
6722
+ *
6723
+ * Returns null when:
6724
+ * - Local sync is not enabled for this course
6725
+ * - Sync has not been triggered yet
6726
+ * - Sync is still in progress
6727
+ * - Sync failed
6728
+ */
6729
+ getLocalDB(courseId) {
6730
+ const entry = this.entries.get(courseId);
6731
+ if (entry?.status.state === "ready" && entry.localDB) {
6732
+ return entry.localDB;
6733
+ }
6734
+ return null;
6735
+ }
6736
+ /**
6737
+ * Check whether a course has a ready local replica.
6738
+ */
6739
+ isReady(courseId) {
6740
+ return this.entries.get(courseId)?.status.state === "ready";
6741
+ }
6742
+ /**
6743
+ * Get detailed sync status for a course.
6744
+ */
6745
+ getStatus(courseId) {
6746
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
6747
+ }
6748
+ // --------------------------------------------------------------------------
6749
+ // Internal
6750
+ // --------------------------------------------------------------------------
6751
+ async performSync(courseId, entry, forceEnabled) {
6752
+ try {
6753
+ if (!forceEnabled) {
6754
+ entry.status = { state: "checking-config" };
6755
+ const enabled = await this.checkLocalSyncEnabled(courseId);
6756
+ if (!enabled) {
6757
+ entry.status = { state: "disabled" };
6758
+ entry.readyPromise = null;
6759
+ logger.debug(
6760
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
6761
+ );
6762
+ return;
6763
+ }
6764
+ }
6765
+ entry.status = { state: "syncing" };
6766
+ const localDBName = this.localDBName(courseId);
6767
+ const localDB = new pouchdb_setup_default(localDBName);
6768
+ entry.localDB = localDB;
6769
+ const remoteDB = this.getRemoteDB(courseId);
6770
+ const syncStart = Date.now();
6771
+ logger.info(
6772
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
6773
+ );
6774
+ const result = await this.replicate(remoteDB, localDB);
6775
+ const syncTimeMs = Date.now() - syncStart;
6776
+ logger.info(
6777
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
6778
+ );
6779
+ entry.status = { state: "warming-views" };
6780
+ const warmStart = Date.now();
6781
+ await this.warmViewIndices(localDB);
6782
+ const viewWarmTimeMs = Date.now() - warmStart;
6783
+ logger.info(
6784
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
6785
+ );
6786
+ entry.status = {
6787
+ state: "ready",
6788
+ docsReplicated: result.docs_written,
6789
+ syncTimeMs,
6790
+ viewWarmTimeMs
6791
+ };
6792
+ } catch (e) {
6793
+ const errorMsg = e instanceof Error ? e.message : String(e);
6794
+ logger.error(
6795
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
6796
+ );
6797
+ entry.status = { state: "error", error: errorMsg };
6798
+ entry.readyPromise = null;
6799
+ if (entry.localDB) {
6800
+ try {
6801
+ await entry.localDB.destroy();
6802
+ } catch {
6803
+ }
6804
+ entry.localDB = null;
6805
+ }
6806
+ }
6807
+ }
6808
+ /**
6809
+ * Check CourseConfig.localSync.enabled on the remote DB.
6810
+ */
6811
+ async checkLocalSyncEnabled(courseId) {
6812
+ try {
6813
+ const remoteDB = this.getRemoteDB(courseId);
6814
+ const config = await remoteDB.get("CourseConfig");
6815
+ return config.localSync?.enabled === true;
6816
+ } catch (e) {
6817
+ logger.warn(
6818
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
6819
+ );
6820
+ return false;
6821
+ }
6822
+ }
6823
+ /**
6824
+ * One-shot replication from remote to local.
6825
+ */
6826
+ replicate(source, target) {
6827
+ return new Promise((resolve, reject) => {
6828
+ void pouchdb_setup_default.replicate(source, target, {
6829
+ // One-shot, not live. Local is a read-only snapshot.
6830
+ }).on("complete", (info) => {
6831
+ resolve(info);
6832
+ }).on("error", (err) => {
6833
+ reject(err);
6834
+ });
6835
+ });
6836
+ }
6837
+ /**
6838
+ * Pre-warm PouchDB view indices by running a minimal query against each
6839
+ * design doc. This forces PouchDB to build the MapReduce index now
6840
+ * (during a loading phase) rather than on first pipeline query.
6841
+ */
6842
+ async warmViewIndices(localDB) {
6843
+ const viewsToWarm = ["elo", "getTags"];
6844
+ for (const viewName of viewsToWarm) {
6845
+ try {
6846
+ await localDB.query(viewName, { limit: 1 });
6847
+ logger.debug(
6848
+ `[CourseSyncService] Warmed view index: ${viewName}`
6849
+ );
6850
+ } catch (e) {
6851
+ logger.debug(
6852
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
6853
+ );
6854
+ }
6855
+ }
6856
+ }
6857
+ /**
6858
+ * Get a remote PouchDB handle for a course.
6859
+ */
6860
+ getRemoteDB(courseId) {
6861
+ return getCourseDB2(courseId);
6862
+ }
6863
+ /**
6864
+ * Local DB naming convention.
6865
+ */
6866
+ localDBName(courseId) {
6867
+ return `coursedb-local-${courseId}`;
6868
+ }
6869
+ };
6870
+ }
6871
+ });
6872
+
6187
6873
  // src/impl/couch/auth.ts
6188
6874
  async function getCurrentSession() {
6189
6875
  try {
@@ -6460,6 +7146,7 @@ __export(couch_exports, {
6460
7146
  ClassroomLookupDB: () => ClassroomLookupDB,
6461
7147
  CouchDBSyncStrategy: () => CouchDBSyncStrategy,
6462
7148
  CourseDB: () => CourseDB,
7149
+ CourseSyncService: () => CourseSyncService,
6463
7150
  CoursesDB: () => CoursesDB,
6464
7151
  REVIEW_TIME_FORMAT: () => REVIEW_TIME_FORMAT,
6465
7152
  StudentClassroomDB: () => StudentClassroomDB,
@@ -6676,6 +7363,7 @@ var init_couch = __esm({
6676
7363
  init_classroomDB2();
6677
7364
  init_courseAPI();
6678
7365
  init_courseDB();
7366
+ init_CourseSyncService();
6679
7367
  init_CouchDBSyncStrategy();
6680
7368
  isBrowser = typeof window !== "undefined";
6681
7369
  if (isBrowser) {
@@ -6701,6 +7389,7 @@ init_couch();
6701
7389
  ClassroomLookupDB,
6702
7390
  CouchDBSyncStrategy,
6703
7391
  CourseDB,
7392
+ CourseSyncService,
6704
7393
  CoursesDB,
6705
7394
  REVIEW_TIME_FORMAT,
6706
7395
  StudentClassroomDB,