@vue-skuilder/db 0.1.31-a → 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.
- package/dist/{contentSource-BmnmvH8C.d.ts → contentSource-Bdwkvqa8.d.ts} +35 -4
- package/dist/{contentSource-DfBbaLA-.d.cts → contentSource-DF1nUbPQ.d.cts} +35 -4
- package/dist/core/index.d.cts +48 -3
- package/dist/core/index.d.ts +48 -3
- package/dist/core/index.js +587 -56
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +586 -56
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BeRXVMs5.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
- package/dist/{dataLayerProvider-CG9GfaAY.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
- package/dist/impl/couch/index.d.cts +156 -4
- package/dist/impl/couch/index.d.ts +156 -4
- package/dist/impl/couch/index.js +805 -47
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +804 -47
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -2
- package/dist/impl/static/index.d.ts +3 -2
- package/dist/impl/static/index.js +542 -37
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +542 -37
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +64 -3
- package/dist/index.d.ts +64 -3
- package/dist/index.js +1040 -90
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1030 -81
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +64 -5
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +6 -0
- package/src/core/interfaces/courseDB.ts +6 -0
- package/src/core/interfaces/dataLayerProvider.ts +20 -0
- package/src/core/navigators/Pipeline.ts +414 -9
- package/src/core/navigators/PipelineAssembler.ts +23 -18
- package/src/core/navigators/PipelineDebugger.ts +115 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
- package/src/core/navigators/generators/prescribed.ts +95 -0
- package/src/core/navigators/index.ts +55 -10
- package/src/impl/common/BaseUserDB.ts +4 -1
- package/src/impl/couch/CourseSyncService.ts +356 -0
- package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
- package/src/impl/couch/courseDB.ts +60 -13
- package/src/impl/couch/index.ts +1 -0
- package/src/impl/static/courseDB.ts +5 -0
- package/src/study/ItemQueue.ts +42 -0
- package/src/study/SessionController.ts +195 -22
- package/src/study/SpacedRepetition.ts +7 -2
- package/tests/core/navigators/Pipeline.test.ts +1 -1
- package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
|
@@ -627,8 +627,12 @@ __export(PipelineDebugger_exports, {
|
|
|
627
627
|
buildRunReport: () => buildRunReport,
|
|
628
628
|
captureRun: () => captureRun,
|
|
629
629
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
630
|
-
pipelineDebugAPI: () => pipelineDebugAPI
|
|
630
|
+
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
631
|
+
registerPipelineForDebug: () => registerPipelineForDebug
|
|
631
632
|
});
|
|
633
|
+
function registerPipelineForDebug(pipeline) {
|
|
634
|
+
_activePipeline = pipeline;
|
|
635
|
+
}
|
|
632
636
|
function getOrigin(card) {
|
|
633
637
|
const firstEntry = card.provenance[0];
|
|
634
638
|
if (!firstEntry) return "unknown";
|
|
@@ -656,6 +660,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
|
|
|
656
660
|
origin: getOrigin(card),
|
|
657
661
|
finalScore: card.score,
|
|
658
662
|
provenance: card.provenance,
|
|
663
|
+
tags: card.tags,
|
|
659
664
|
selected: selectedIds.has(card.cardId)
|
|
660
665
|
}));
|
|
661
666
|
const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
|
|
@@ -711,11 +716,13 @@ function mountPipelineDebugger() {
|
|
|
711
716
|
win.skuilder = win.skuilder || {};
|
|
712
717
|
win.skuilder.pipeline = pipelineDebugAPI;
|
|
713
718
|
}
|
|
714
|
-
var MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
719
|
+
var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
|
|
715
720
|
var init_PipelineDebugger = __esm({
|
|
716
721
|
"src/core/navigators/PipelineDebugger.ts"() {
|
|
717
722
|
"use strict";
|
|
723
|
+
init_navigators();
|
|
718
724
|
init_logger();
|
|
725
|
+
_activePipeline = null;
|
|
719
726
|
MAX_RUNS = 10;
|
|
720
727
|
runHistory = [];
|
|
721
728
|
pipelineDebugAPI = {
|
|
@@ -857,6 +864,81 @@ var init_PipelineDebugger = __esm({
|
|
|
857
864
|
runHistory.length = 0;
|
|
858
865
|
logger.info("[Pipeline Debug] Run history cleared.");
|
|
859
866
|
},
|
|
867
|
+
/**
|
|
868
|
+
* Show the navigator registry: all registered classes and their roles.
|
|
869
|
+
*
|
|
870
|
+
* Useful for verifying that consumer-defined navigators were registered
|
|
871
|
+
* before pipeline assembly.
|
|
872
|
+
*/
|
|
873
|
+
showRegistry() {
|
|
874
|
+
const names = getRegisteredNavigatorNames();
|
|
875
|
+
if (names.length === 0) {
|
|
876
|
+
logger.info("[Pipeline Debug] Navigator registry is empty.");
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
console.group("\u{1F4E6} Navigator Registry");
|
|
880
|
+
console.table(
|
|
881
|
+
names.map((name) => {
|
|
882
|
+
const registryRole = getRegisteredNavigatorRole(name);
|
|
883
|
+
const builtinRole = NavigatorRoles[name];
|
|
884
|
+
const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
|
|
885
|
+
const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
|
|
886
|
+
return {
|
|
887
|
+
name,
|
|
888
|
+
role: effectiveRole,
|
|
889
|
+
source,
|
|
890
|
+
isGenerator: isGenerator(name),
|
|
891
|
+
isFilter: isFilter(name)
|
|
892
|
+
};
|
|
893
|
+
})
|
|
894
|
+
);
|
|
895
|
+
console.groupEnd();
|
|
896
|
+
},
|
|
897
|
+
/**
|
|
898
|
+
* Show strategy documents from the last pipeline run and how they mapped
|
|
899
|
+
* to the registry.
|
|
900
|
+
*
|
|
901
|
+
* If no runs are captured yet, falls back to showing just the registry.
|
|
902
|
+
*/
|
|
903
|
+
showStrategies() {
|
|
904
|
+
this.showRegistry();
|
|
905
|
+
if (runHistory.length === 0) {
|
|
906
|
+
logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const run = runHistory[0];
|
|
910
|
+
console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
|
|
911
|
+
logger.info(`Generator: ${run.generatorName}`);
|
|
912
|
+
if (run.generators && run.generators.length > 0) {
|
|
913
|
+
for (const g of run.generators) {
|
|
914
|
+
logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (run.filters.length > 0) {
|
|
918
|
+
logger.info("Filters:");
|
|
919
|
+
for (const f of run.filters) {
|
|
920
|
+
logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
logger.info("Filters: (none)");
|
|
924
|
+
}
|
|
925
|
+
console.groupEnd();
|
|
926
|
+
},
|
|
927
|
+
/**
|
|
928
|
+
* Scan the full card space through the filter chain for the current user.
|
|
929
|
+
*
|
|
930
|
+
* Reports how many cards are well-indicated and how many are new.
|
|
931
|
+
* Use this to understand how the search space grows during onboarding.
|
|
932
|
+
*
|
|
933
|
+
* @param threshold - Score threshold for "well indicated" (default 0.10)
|
|
934
|
+
*/
|
|
935
|
+
async diagnoseCardSpace(threshold) {
|
|
936
|
+
if (!_activePipeline) {
|
|
937
|
+
logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
return _activePipeline.diagnoseCardSpace({ threshold });
|
|
941
|
+
},
|
|
860
942
|
/**
|
|
861
943
|
* Show help.
|
|
862
944
|
*/
|
|
@@ -869,6 +951,9 @@ Commands:
|
|
|
869
951
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
870
952
|
.showCard(cardId) Show provenance trail for a specific card
|
|
871
953
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
954
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
955
|
+
.showRegistry() Show navigator registry (classes + roles)
|
|
956
|
+
.showStrategies() Show registry + strategy mapping from last run
|
|
872
957
|
.listRuns() List all captured runs in table format
|
|
873
958
|
.export() Export run history as JSON for bug reports
|
|
874
959
|
.clear() Clear run history
|
|
@@ -878,7 +963,7 @@ Commands:
|
|
|
878
963
|
Example:
|
|
879
964
|
window.skuilder.pipeline.showLastRun()
|
|
880
965
|
window.skuilder.pipeline.showRun(1)
|
|
881
|
-
window.skuilder.pipeline.
|
|
966
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
882
967
|
`);
|
|
883
968
|
}
|
|
884
969
|
};
|
|
@@ -1173,6 +1258,69 @@ var init_generators = __esm({
|
|
|
1173
1258
|
}
|
|
1174
1259
|
});
|
|
1175
1260
|
|
|
1261
|
+
// src/core/navigators/generators/prescribed.ts
|
|
1262
|
+
var prescribed_exports = {};
|
|
1263
|
+
__export(prescribed_exports, {
|
|
1264
|
+
default: () => PrescribedCardsGenerator
|
|
1265
|
+
});
|
|
1266
|
+
var PrescribedCardsGenerator;
|
|
1267
|
+
var init_prescribed = __esm({
|
|
1268
|
+
"src/core/navigators/generators/prescribed.ts"() {
|
|
1269
|
+
"use strict";
|
|
1270
|
+
init_navigators();
|
|
1271
|
+
init_logger();
|
|
1272
|
+
PrescribedCardsGenerator = class extends ContentNavigator {
|
|
1273
|
+
name;
|
|
1274
|
+
config;
|
|
1275
|
+
constructor(user, course, strategyData) {
|
|
1276
|
+
super(user, course, strategyData);
|
|
1277
|
+
this.name = strategyData.name || "Prescribed Cards";
|
|
1278
|
+
try {
|
|
1279
|
+
const parsed = JSON.parse(strategyData.serializedData);
|
|
1280
|
+
this.config = { cardIds: parsed.cardIds || [] };
|
|
1281
|
+
} catch {
|
|
1282
|
+
this.config = { cardIds: [] };
|
|
1283
|
+
}
|
|
1284
|
+
logger.debug(
|
|
1285
|
+
`[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
async getWeightedCards(limit, _context) {
|
|
1289
|
+
if (this.config.cardIds.length === 0) {
|
|
1290
|
+
return [];
|
|
1291
|
+
}
|
|
1292
|
+
const courseId = this.course.getCourseID();
|
|
1293
|
+
const activeCards = await this.user.getActiveCards();
|
|
1294
|
+
const activeIds = new Set(activeCards.map((ac) => ac.cardID));
|
|
1295
|
+
const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
|
|
1296
|
+
if (eligibleIds.length === 0) {
|
|
1297
|
+
logger.debug("[Prescribed] All prescribed cards already active, returning empty");
|
|
1298
|
+
return [];
|
|
1299
|
+
}
|
|
1300
|
+
const cards = eligibleIds.slice(0, limit).map((cardId) => ({
|
|
1301
|
+
cardId,
|
|
1302
|
+
courseId,
|
|
1303
|
+
score: 1,
|
|
1304
|
+
provenance: [
|
|
1305
|
+
{
|
|
1306
|
+
strategy: "prescribed",
|
|
1307
|
+
strategyName: this.strategyName || this.name,
|
|
1308
|
+
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
1309
|
+
action: "generated",
|
|
1310
|
+
score: 1,
|
|
1311
|
+
reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
|
|
1312
|
+
}
|
|
1313
|
+
]
|
|
1314
|
+
}));
|
|
1315
|
+
logger.info(
|
|
1316
|
+
`[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
|
|
1317
|
+
);
|
|
1318
|
+
return cards;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1176
1324
|
// src/core/navigators/generators/srs.ts
|
|
1177
1325
|
var srs_exports = {};
|
|
1178
1326
|
__export(srs_exports, {
|
|
@@ -1367,6 +1515,7 @@ var init_ = __esm({
|
|
|
1367
1515
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1368
1516
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1369
1517
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1518
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1370
1519
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1371
1520
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1372
1521
|
});
|
|
@@ -1567,6 +1716,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1567
1716
|
if (userTagElo.count < minCount) return false;
|
|
1568
1717
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1569
1718
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1719
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1720
|
+
return true;
|
|
1570
1721
|
} else {
|
|
1571
1722
|
return userTagElo.score >= userGlobalElo;
|
|
1572
1723
|
}
|
|
@@ -1642,14 +1793,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1642
1793
|
};
|
|
1643
1794
|
}
|
|
1644
1795
|
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Build a map of prereq tag → max configured boost for all *closed* gates.
|
|
1798
|
+
*
|
|
1799
|
+
* When a gate is closed (prereqs unmet), cards carrying that gate's prereq
|
|
1800
|
+
* tags get boosted — steering the pipeline toward content that helps unlock
|
|
1801
|
+
* the gated material. Once the gate opens, the boost disappears.
|
|
1802
|
+
*/
|
|
1803
|
+
getPreReqBoosts(unlockedTags, masteredTags) {
|
|
1804
|
+
const boosts = /* @__PURE__ */ new Map();
|
|
1805
|
+
for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
|
|
1806
|
+
if (unlockedTags.has(tagId)) continue;
|
|
1807
|
+
for (const prereq of prereqs) {
|
|
1808
|
+
if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
|
|
1809
|
+
if (masteredTags.has(prereq.tag)) continue;
|
|
1810
|
+
const existing = boosts.get(prereq.tag) ?? 1;
|
|
1811
|
+
boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
return boosts;
|
|
1815
|
+
}
|
|
1645
1816
|
/**
|
|
1646
1817
|
* CardFilter.transform implementation.
|
|
1647
1818
|
*
|
|
1648
|
-
*
|
|
1819
|
+
* Two effects:
|
|
1820
|
+
* 1. Cards with locked tags receive score * 0.05 (gating penalty)
|
|
1821
|
+
* 2. Cards carrying prereq tags of closed gates receive a configured
|
|
1822
|
+
* boost (preReqBoost), steering toward content that unlocks gates
|
|
1649
1823
|
*/
|
|
1650
1824
|
async transform(cards, context) {
|
|
1651
1825
|
const masteredTags = await this.getMasteredTags(context);
|
|
1652
1826
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1827
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1653
1828
|
const gated = [];
|
|
1654
1829
|
for (const card of cards) {
|
|
1655
1830
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1658,9 +1833,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1658
1833
|
unlockedTags,
|
|
1659
1834
|
masteredTags
|
|
1660
1835
|
);
|
|
1661
|
-
const LOCKED_PENALTY = 0.
|
|
1662
|
-
|
|
1663
|
-
|
|
1836
|
+
const LOCKED_PENALTY = 0.02;
|
|
1837
|
+
let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
|
|
1838
|
+
let action = isUnlocked ? "passed" : "penalized";
|
|
1839
|
+
let finalReason = reason;
|
|
1840
|
+
if (isUnlocked && preReqBoosts.size > 0) {
|
|
1841
|
+
const cardTags = card.tags ?? [];
|
|
1842
|
+
let maxBoost = 1;
|
|
1843
|
+
const boostedPrereqs = [];
|
|
1844
|
+
for (const tag of cardTags) {
|
|
1845
|
+
const boost = preReqBoosts.get(tag);
|
|
1846
|
+
if (boost && boost > maxBoost) {
|
|
1847
|
+
maxBoost = boost;
|
|
1848
|
+
boostedPrereqs.push(tag);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (maxBoost > 1) {
|
|
1852
|
+
finalScore *= maxBoost;
|
|
1853
|
+
action = "boosted";
|
|
1854
|
+
finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1664
1857
|
gated.push({
|
|
1665
1858
|
...card,
|
|
1666
1859
|
score: finalScore,
|
|
@@ -1672,7 +1865,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1672
1865
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1673
1866
|
action,
|
|
1674
1867
|
score: finalScore,
|
|
1675
|
-
reason
|
|
1868
|
+
reason: finalReason
|
|
1676
1869
|
}
|
|
1677
1870
|
]
|
|
1678
1871
|
});
|
|
@@ -2361,6 +2554,18 @@ __export(Pipeline_exports, {
|
|
|
2361
2554
|
Pipeline: () => Pipeline
|
|
2362
2555
|
});
|
|
2363
2556
|
import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
|
|
2557
|
+
function globToRegex(pattern) {
|
|
2558
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2559
|
+
const withWildcards = escaped.replace(/\*/g, ".*");
|
|
2560
|
+
return new RegExp(`^${withWildcards}$`);
|
|
2561
|
+
}
|
|
2562
|
+
function globMatch(value, pattern) {
|
|
2563
|
+
if (!pattern.includes("*")) return value === pattern;
|
|
2564
|
+
return globToRegex(pattern).test(value);
|
|
2565
|
+
}
|
|
2566
|
+
function cardMatchesTagPattern(card, pattern) {
|
|
2567
|
+
return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
|
|
2568
|
+
}
|
|
2364
2569
|
function logPipelineConfig(generator, filters) {
|
|
2365
2570
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2366
2571
|
logger.info(
|
|
@@ -2395,6 +2600,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2395
2600
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2396
2601
|
);
|
|
2397
2602
|
}
|
|
2603
|
+
function logResultCards(cards) {
|
|
2604
|
+
if (!VERBOSE_RESULTS || cards.length === 0) return;
|
|
2605
|
+
logger.info(`[Pipeline] Results (${cards.length} cards):`);
|
|
2606
|
+
for (let i = 0; i < cards.length; i++) {
|
|
2607
|
+
const c = cards[i];
|
|
2608
|
+
const tags = c.tags?.slice(0, 3).join(", ") || "";
|
|
2609
|
+
const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
|
|
2610
|
+
const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
|
|
2611
|
+
return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
|
|
2612
|
+
}).join(" | ");
|
|
2613
|
+
logger.info(
|
|
2614
|
+
`[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2398
2618
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2399
2619
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2400
2620
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2409,7 +2629,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2409
2629
|
}
|
|
2410
2630
|
}
|
|
2411
2631
|
}
|
|
2412
|
-
var Pipeline;
|
|
2632
|
+
var VERBOSE_RESULTS, Pipeline;
|
|
2413
2633
|
var init_Pipeline = __esm({
|
|
2414
2634
|
"src/core/navigators/Pipeline.ts"() {
|
|
2415
2635
|
"use strict";
|
|
@@ -2417,9 +2637,31 @@ var init_Pipeline = __esm({
|
|
|
2417
2637
|
init_logger();
|
|
2418
2638
|
init_orchestration();
|
|
2419
2639
|
init_PipelineDebugger();
|
|
2640
|
+
VERBOSE_RESULTS = true;
|
|
2420
2641
|
Pipeline = class extends ContentNavigator {
|
|
2421
2642
|
generator;
|
|
2422
2643
|
filters;
|
|
2644
|
+
/**
|
|
2645
|
+
* Cached orchestration context. Course config and salt don't change within
|
|
2646
|
+
* a page load, so we build the orchestration context once and reuse it on
|
|
2647
|
+
* subsequent getWeightedCards() calls (e.g. mid-session replans).
|
|
2648
|
+
*
|
|
2649
|
+
* This eliminates a remote getCourseConfig() round trip per pipeline run.
|
|
2650
|
+
*/
|
|
2651
|
+
_cachedOrchestration = null;
|
|
2652
|
+
/**
|
|
2653
|
+
* Persistent tag cache. Maps cardId → tag names.
|
|
2654
|
+
*
|
|
2655
|
+
* Tags are static within a session (they're set at card generation time),
|
|
2656
|
+
* so we cache them across pipeline runs. On replans, many of the same cards
|
|
2657
|
+
* reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
|
|
2658
|
+
*/
|
|
2659
|
+
_tagCache = /* @__PURE__ */ new Map();
|
|
2660
|
+
/**
|
|
2661
|
+
* One-shot replan hints. Applied after the filter chain on the next
|
|
2662
|
+
* getWeightedCards() call, then cleared.
|
|
2663
|
+
*/
|
|
2664
|
+
_ephemeralHints = null;
|
|
2423
2665
|
/**
|
|
2424
2666
|
* Create a new pipeline.
|
|
2425
2667
|
*
|
|
@@ -2440,6 +2682,17 @@ var init_Pipeline = __esm({
|
|
|
2440
2682
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2441
2683
|
});
|
|
2442
2684
|
logPipelineConfig(generator, filters);
|
|
2685
|
+
registerPipelineForDebug(this);
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Set one-shot hints for the next pipeline run.
|
|
2689
|
+
* Consumed after one getWeightedCards() call, then cleared.
|
|
2690
|
+
*
|
|
2691
|
+
* Overrides ContentNavigator.setEphemeralHints() no-op.
|
|
2692
|
+
*/
|
|
2693
|
+
setEphemeralHints(hints) {
|
|
2694
|
+
this._ephemeralHints = hints;
|
|
2695
|
+
logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
|
|
2443
2696
|
}
|
|
2444
2697
|
/**
|
|
2445
2698
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2456,13 +2709,15 @@ var init_Pipeline = __esm({
|
|
|
2456
2709
|
* @returns Cards sorted by score descending
|
|
2457
2710
|
*/
|
|
2458
2711
|
async getWeightedCards(limit) {
|
|
2712
|
+
const t0 = performance.now();
|
|
2459
2713
|
const context = await this.buildContext();
|
|
2460
|
-
const
|
|
2461
|
-
const fetchLimit =
|
|
2714
|
+
const tContext = performance.now();
|
|
2715
|
+
const fetchLimit = 500;
|
|
2462
2716
|
logger.debug(
|
|
2463
2717
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2464
2718
|
);
|
|
2465
2719
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2720
|
+
const tGenerate = performance.now();
|
|
2466
2721
|
const generatedCount = cards.length;
|
|
2467
2722
|
let generatorSummaries;
|
|
2468
2723
|
if (this.generator.generators) {
|
|
@@ -2491,6 +2746,7 @@ var init_Pipeline = __esm({
|
|
|
2491
2746
|
}
|
|
2492
2747
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2493
2748
|
cards = await this.hydrateTags(cards);
|
|
2749
|
+
const tHydrate = performance.now();
|
|
2494
2750
|
const allCardsBeforeFiltering = [...cards];
|
|
2495
2751
|
const filterImpacts = [];
|
|
2496
2752
|
for (const filter of this.filters) {
|
|
@@ -2509,8 +2765,17 @@ var init_Pipeline = __esm({
|
|
|
2509
2765
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2510
2766
|
}
|
|
2511
2767
|
cards = cards.filter((c) => c.score > 0);
|
|
2768
|
+
const hints = this._ephemeralHints;
|
|
2769
|
+
if (hints) {
|
|
2770
|
+
this._ephemeralHints = null;
|
|
2771
|
+
cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
|
|
2772
|
+
}
|
|
2512
2773
|
cards.sort((a, b) => b.score - a.score);
|
|
2774
|
+
const tFilter = performance.now();
|
|
2513
2775
|
const result = cards.slice(0, limit);
|
|
2776
|
+
logger.info(
|
|
2777
|
+
`[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)})`
|
|
2778
|
+
);
|
|
2514
2779
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2515
2780
|
logExecutionSummary(
|
|
2516
2781
|
this.generator.name,
|
|
@@ -2520,6 +2785,7 @@ var init_Pipeline = __esm({
|
|
|
2520
2785
|
topScores,
|
|
2521
2786
|
filterImpacts
|
|
2522
2787
|
);
|
|
2788
|
+
logResultCards(result);
|
|
2523
2789
|
logCardProvenance(result, 3);
|
|
2524
2790
|
try {
|
|
2525
2791
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -2546,6 +2812,10 @@ var init_Pipeline = __esm({
|
|
|
2546
2812
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
2547
2813
|
* making individual getAppliedTags() calls.
|
|
2548
2814
|
*
|
|
2815
|
+
* Uses a persistent tag cache across pipeline runs — tags are static within
|
|
2816
|
+
* a session, so cards seen in a prior run (e.g. before a replan) don't
|
|
2817
|
+
* require a second DB query.
|
|
2818
|
+
*
|
|
2549
2819
|
* @param cards - Cards to hydrate
|
|
2550
2820
|
* @returns Cards with tags populated
|
|
2551
2821
|
*/
|
|
@@ -2553,14 +2823,128 @@ var init_Pipeline = __esm({
|
|
|
2553
2823
|
if (cards.length === 0) {
|
|
2554
2824
|
return cards;
|
|
2555
2825
|
}
|
|
2556
|
-
const
|
|
2557
|
-
const
|
|
2826
|
+
const uncachedIds = [];
|
|
2827
|
+
for (const card of cards) {
|
|
2828
|
+
if (!this._tagCache.has(card.cardId)) {
|
|
2829
|
+
uncachedIds.push(card.cardId);
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
if (uncachedIds.length > 0) {
|
|
2833
|
+
const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
|
|
2834
|
+
for (const [cardId, tags] of freshTags) {
|
|
2835
|
+
this._tagCache.set(cardId, tags);
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
const tagsByCard = /* @__PURE__ */ new Map();
|
|
2839
|
+
for (const card of cards) {
|
|
2840
|
+
tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
|
|
2841
|
+
}
|
|
2558
2842
|
logTagHydration(cards, tagsByCard);
|
|
2559
2843
|
return cards.map((card) => ({
|
|
2560
2844
|
...card,
|
|
2561
|
-
tags:
|
|
2845
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
2562
2846
|
}));
|
|
2563
2847
|
}
|
|
2848
|
+
// ---------------------------------------------------------------------------
|
|
2849
|
+
// Ephemeral hints application
|
|
2850
|
+
// ---------------------------------------------------------------------------
|
|
2851
|
+
/**
|
|
2852
|
+
* Apply one-shot replan hints to the post-filter card set.
|
|
2853
|
+
*
|
|
2854
|
+
* Order of operations:
|
|
2855
|
+
* 1. Exclude (remove unwanted cards)
|
|
2856
|
+
* 2. Boost (multiply scores)
|
|
2857
|
+
* 3. Require (inject must-have cards from the full pre-filter pool)
|
|
2858
|
+
*
|
|
2859
|
+
* @param cards - Post-filter cards (score > 0)
|
|
2860
|
+
* @param hints - The ephemeral hints to apply
|
|
2861
|
+
* @param allCards - Full pre-filter card pool (for require injection)
|
|
2862
|
+
*/
|
|
2863
|
+
applyHints(cards, hints, allCards) {
|
|
2864
|
+
const beforeCount = cards.length;
|
|
2865
|
+
if (hints.excludeCards?.length) {
|
|
2866
|
+
cards = cards.filter(
|
|
2867
|
+
(c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
if (hints.excludeTags?.length) {
|
|
2871
|
+
cards = cards.filter(
|
|
2872
|
+
(c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
if (hints.boostTags) {
|
|
2876
|
+
for (const [pattern, factor] of Object.entries(hints.boostTags)) {
|
|
2877
|
+
for (const card of cards) {
|
|
2878
|
+
if (cardMatchesTagPattern(card, pattern)) {
|
|
2879
|
+
card.score *= factor;
|
|
2880
|
+
card.provenance.push({
|
|
2881
|
+
strategy: "ephemeralHint",
|
|
2882
|
+
strategyId: "ephemeral-hint",
|
|
2883
|
+
strategyName: "Replan Hint",
|
|
2884
|
+
action: "boosted",
|
|
2885
|
+
score: card.score,
|
|
2886
|
+
reason: `boostTag ${pattern} \xD7${factor}`
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (hints.boostCards) {
|
|
2893
|
+
for (const [pattern, factor] of Object.entries(hints.boostCards)) {
|
|
2894
|
+
for (const card of cards) {
|
|
2895
|
+
if (globMatch(card.cardId, pattern)) {
|
|
2896
|
+
card.score *= factor;
|
|
2897
|
+
card.provenance.push({
|
|
2898
|
+
strategy: "ephemeralHint",
|
|
2899
|
+
strategyId: "ephemeral-hint",
|
|
2900
|
+
strategyName: "Replan Hint",
|
|
2901
|
+
action: "boosted",
|
|
2902
|
+
score: card.score,
|
|
2903
|
+
reason: `boostCard ${pattern} \xD7${factor}`
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
const cardIds = new Set(cards.map((c) => c.cardId));
|
|
2910
|
+
const inject = (card, reason) => {
|
|
2911
|
+
if (!cardIds.has(card.cardId)) {
|
|
2912
|
+
const floorScore = Math.max(card.score, 1);
|
|
2913
|
+
cards.push({
|
|
2914
|
+
...card,
|
|
2915
|
+
score: floorScore,
|
|
2916
|
+
provenance: [
|
|
2917
|
+
...card.provenance,
|
|
2918
|
+
{
|
|
2919
|
+
strategy: "ephemeralHint",
|
|
2920
|
+
strategyId: "ephemeral-hint",
|
|
2921
|
+
strategyName: "Replan Hint",
|
|
2922
|
+
action: "boosted",
|
|
2923
|
+
score: floorScore,
|
|
2924
|
+
reason
|
|
2925
|
+
}
|
|
2926
|
+
]
|
|
2927
|
+
});
|
|
2928
|
+
cardIds.add(card.cardId);
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
if (hints.requireCards?.length) {
|
|
2932
|
+
for (const pattern of hints.requireCards) {
|
|
2933
|
+
for (const card of allCards) {
|
|
2934
|
+
if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
if (hints.requireTags?.length) {
|
|
2939
|
+
for (const pattern of hints.requireTags) {
|
|
2940
|
+
for (const card of allCards) {
|
|
2941
|
+
if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
|
|
2946
|
+
return cards;
|
|
2947
|
+
}
|
|
2564
2948
|
/**
|
|
2565
2949
|
* Build shared context for generator and filters.
|
|
2566
2950
|
*
|
|
@@ -2578,7 +2962,10 @@ var init_Pipeline = __esm({
|
|
|
2578
2962
|
} catch (e) {
|
|
2579
2963
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
2580
2964
|
}
|
|
2581
|
-
|
|
2965
|
+
if (!this._cachedOrchestration) {
|
|
2966
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
2967
|
+
}
|
|
2968
|
+
const orchestration = this._cachedOrchestration;
|
|
2582
2969
|
return {
|
|
2583
2970
|
user: this.user,
|
|
2584
2971
|
course: this.course,
|
|
@@ -2622,6 +3009,87 @@ var init_Pipeline = __esm({
|
|
|
2622
3009
|
}
|
|
2623
3010
|
return [...new Set(ids)];
|
|
2624
3011
|
}
|
|
3012
|
+
// ---------------------------------------------------------------------------
|
|
3013
|
+
// Card-space diagnostic
|
|
3014
|
+
// ---------------------------------------------------------------------------
|
|
3015
|
+
/**
|
|
3016
|
+
* Scan every card in the course through the filter chain and report
|
|
3017
|
+
* how many are "well indicated" (score >= threshold) for the current user.
|
|
3018
|
+
*
|
|
3019
|
+
* Also reports how many well-indicated cards the user has NOT yet encountered.
|
|
3020
|
+
*
|
|
3021
|
+
* Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
|
|
3022
|
+
*/
|
|
3023
|
+
async diagnoseCardSpace(opts) {
|
|
3024
|
+
const THRESHOLD = opts?.threshold ?? 0.1;
|
|
3025
|
+
const t0 = performance.now();
|
|
3026
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
3027
|
+
let cards = allCardIds.map((cardId) => ({
|
|
3028
|
+
cardId,
|
|
3029
|
+
courseId: this.course.getCourseID(),
|
|
3030
|
+
score: 1,
|
|
3031
|
+
provenance: []
|
|
3032
|
+
}));
|
|
3033
|
+
cards = await this.hydrateTags(cards);
|
|
3034
|
+
const context = await this.buildContext();
|
|
3035
|
+
const filterBreakdown = [];
|
|
3036
|
+
for (const filter of this.filters) {
|
|
3037
|
+
cards = await filter.transform(cards, context);
|
|
3038
|
+
const wi = cards.filter((c) => c.score >= THRESHOLD).length;
|
|
3039
|
+
filterBreakdown.push({ name: filter.name, wellIndicated: wi });
|
|
3040
|
+
}
|
|
3041
|
+
const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
|
|
3042
|
+
const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
|
|
3043
|
+
let encounteredIds;
|
|
3044
|
+
try {
|
|
3045
|
+
const courseId = this.course.getCourseID();
|
|
3046
|
+
const seenCards = await this.user.getSeenCards(courseId);
|
|
3047
|
+
encounteredIds = new Set(seenCards);
|
|
3048
|
+
} catch {
|
|
3049
|
+
encounteredIds = /* @__PURE__ */ new Set();
|
|
3050
|
+
}
|
|
3051
|
+
const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
|
|
3052
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3053
|
+
for (const card of cards) {
|
|
3054
|
+
const type = card.cardId.split("-")[1] || "unknown";
|
|
3055
|
+
if (!byType.has(type)) {
|
|
3056
|
+
byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
|
|
3057
|
+
}
|
|
3058
|
+
const entry = byType.get(type);
|
|
3059
|
+
entry.total++;
|
|
3060
|
+
if (card.score >= THRESHOLD) {
|
|
3061
|
+
entry.wellIndicated++;
|
|
3062
|
+
if (!encounteredIds.has(card.cardId)) entry.new++;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const elapsed = performance.now() - t0;
|
|
3066
|
+
const result = {
|
|
3067
|
+
totalCards: allCardIds.length,
|
|
3068
|
+
threshold: THRESHOLD,
|
|
3069
|
+
wellIndicated: wellIndicatedIds.size,
|
|
3070
|
+
encountered: encounteredIds.size,
|
|
3071
|
+
wellIndicatedNew: wellIndicatedNew.length,
|
|
3072
|
+
byType: Object.fromEntries(byType),
|
|
3073
|
+
filterBreakdown,
|
|
3074
|
+
elapsedMs: Math.round(elapsed)
|
|
3075
|
+
};
|
|
3076
|
+
logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
|
|
3077
|
+
logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
|
|
3078
|
+
logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
|
|
3079
|
+
logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
|
|
3080
|
+
logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
|
|
3081
|
+
logger.info(`[Pipeline:diagnose] By type:`);
|
|
3082
|
+
for (const [type, counts] of byType) {
|
|
3083
|
+
logger.info(
|
|
3084
|
+
`[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
logger.info(`[Pipeline:diagnose] After each filter:`);
|
|
3088
|
+
for (const fb of filterBreakdown) {
|
|
3089
|
+
logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
|
|
3090
|
+
}
|
|
3091
|
+
return result;
|
|
3092
|
+
}
|
|
2625
3093
|
};
|
|
2626
3094
|
}
|
|
2627
3095
|
});
|
|
@@ -2726,23 +3194,25 @@ var init_PipelineAssembler = __esm({
|
|
|
2726
3194
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
2727
3195
|
}
|
|
2728
3196
|
}
|
|
3197
|
+
const courseId = course.getCourseID();
|
|
3198
|
+
const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
|
|
3199
|
+
const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
|
|
3200
|
+
if (!hasElo) {
|
|
3201
|
+
logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
|
|
3202
|
+
generatorStrategies.push(createDefaultEloStrategy(courseId));
|
|
3203
|
+
}
|
|
3204
|
+
if (!hasSrs) {
|
|
3205
|
+
logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
|
|
3206
|
+
generatorStrategies.push(createDefaultSrsStrategy(courseId));
|
|
3207
|
+
}
|
|
2729
3208
|
if (generatorStrategies.length === 0) {
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
} else {
|
|
2738
|
-
warnings.push("No generator strategy found");
|
|
2739
|
-
return {
|
|
2740
|
-
pipeline: null,
|
|
2741
|
-
generatorStrategies: [],
|
|
2742
|
-
filterStrategies: [],
|
|
2743
|
-
warnings
|
|
2744
|
-
};
|
|
2745
|
-
}
|
|
3209
|
+
warnings.push("No generator strategy found");
|
|
3210
|
+
return {
|
|
3211
|
+
pipeline: null,
|
|
3212
|
+
generatorStrategies: [],
|
|
3213
|
+
filterStrategies: [],
|
|
3214
|
+
warnings
|
|
3215
|
+
};
|
|
2746
3216
|
}
|
|
2747
3217
|
let generator;
|
|
2748
3218
|
if (generatorStrategies.length === 1) {
|
|
@@ -2820,6 +3290,7 @@ var init_3 = __esm({
|
|
|
2820
3290
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2821
3291
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2822
3292
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3293
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
2823
3294
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2824
3295
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2825
3296
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -2837,6 +3308,7 @@ __export(navigators_exports, {
|
|
|
2837
3308
|
getCardOrigin: () => getCardOrigin,
|
|
2838
3309
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
2839
3310
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
3311
|
+
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
2840
3312
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
2841
3313
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
2842
3314
|
isFilter: () => isFilter,
|
|
@@ -2845,16 +3317,19 @@ __export(navigators_exports, {
|
|
|
2845
3317
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
2846
3318
|
registerNavigator: () => registerNavigator
|
|
2847
3319
|
});
|
|
2848
|
-
function registerNavigator(implementingClass, constructor) {
|
|
2849
|
-
navigatorRegistry.set(implementingClass, constructor);
|
|
2850
|
-
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
3320
|
+
function registerNavigator(implementingClass, constructor, role) {
|
|
3321
|
+
navigatorRegistry.set(implementingClass, { constructor, role });
|
|
3322
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
|
|
2851
3323
|
}
|
|
2852
3324
|
function getRegisteredNavigator(implementingClass) {
|
|
2853
|
-
return navigatorRegistry.get(implementingClass);
|
|
3325
|
+
return navigatorRegistry.get(implementingClass)?.constructor;
|
|
2854
3326
|
}
|
|
2855
3327
|
function hasRegisteredNavigator(implementingClass) {
|
|
2856
3328
|
return navigatorRegistry.has(implementingClass);
|
|
2857
3329
|
}
|
|
3330
|
+
function getRegisteredNavigatorRole(implementingClass) {
|
|
3331
|
+
return navigatorRegistry.get(implementingClass)?.role;
|
|
3332
|
+
}
|
|
2858
3333
|
function getRegisteredNavigatorNames() {
|
|
2859
3334
|
return Array.from(navigatorRegistry.keys());
|
|
2860
3335
|
}
|
|
@@ -2864,8 +3339,10 @@ async function initializeNavigatorRegistry() {
|
|
|
2864
3339
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2865
3340
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2866
3341
|
]);
|
|
3342
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
2867
3343
|
registerNavigator("elo", eloModule.default);
|
|
2868
3344
|
registerNavigator("srs", srsModule.default);
|
|
3345
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
2869
3346
|
const [
|
|
2870
3347
|
hierarchyModule,
|
|
2871
3348
|
interferenceModule,
|
|
@@ -2900,10 +3377,12 @@ function getCardOrigin(card) {
|
|
|
2900
3377
|
return "new";
|
|
2901
3378
|
}
|
|
2902
3379
|
function isGenerator(impl) {
|
|
2903
|
-
|
|
3380
|
+
if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
|
|
3381
|
+
return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
|
|
2904
3382
|
}
|
|
2905
3383
|
function isFilter(impl) {
|
|
2906
|
-
|
|
3384
|
+
if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
|
|
3385
|
+
return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
|
|
2907
3386
|
}
|
|
2908
3387
|
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2909
3388
|
var init_navigators = __esm({
|
|
@@ -2918,6 +3397,7 @@ var init_navigators = __esm({
|
|
|
2918
3397
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2919
3398
|
Navigators2["ELO"] = "elo";
|
|
2920
3399
|
Navigators2["SRS"] = "srs";
|
|
3400
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
2921
3401
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2922
3402
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2923
3403
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -2932,6 +3412,7 @@ var init_navigators = __esm({
|
|
|
2932
3412
|
NavigatorRoles = {
|
|
2933
3413
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2934
3414
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3415
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
2935
3416
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2936
3417
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2937
3418
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3096,6 +3577,12 @@ var init_navigators = __esm({
|
|
|
3096
3577
|
async getWeightedCards(_limit) {
|
|
3097
3578
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3098
3579
|
}
|
|
3580
|
+
/**
|
|
3581
|
+
* Set ephemeral hints for the next pipeline run.
|
|
3582
|
+
* No-op for non-Pipeline navigators. Pipeline overrides this.
|
|
3583
|
+
*/
|
|
3584
|
+
setEphemeralHints(_hints) {
|
|
3585
|
+
}
|
|
3099
3586
|
};
|
|
3100
3587
|
}
|
|
3101
3588
|
});
|
|
@@ -3287,15 +3774,42 @@ var init_courseDB = __esm({
|
|
|
3287
3774
|
// private log(msg: string): void {
|
|
3288
3775
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3289
3776
|
// }
|
|
3777
|
+
/**
|
|
3778
|
+
* Primary database handle used for all **read** operations (queries, gets).
|
|
3779
|
+
*
|
|
3780
|
+
* When local sync is active, this points to the local PouchDB replica for
|
|
3781
|
+
* fast, network-free reads. Otherwise it points to the remote CouchDB.
|
|
3782
|
+
*/
|
|
3290
3783
|
db;
|
|
3784
|
+
/**
|
|
3785
|
+
* Remote database handle used for all **write** operations.
|
|
3786
|
+
*
|
|
3787
|
+
* Always points to the remote CouchDB so that writes (ELO updates, tag
|
|
3788
|
+
* mutations, admin operations) aggregate on the server. The local replica
|
|
3789
|
+
* is a read-only snapshot that refreshes on the next page load.
|
|
3790
|
+
*
|
|
3791
|
+
* When local sync is NOT active, this is the same instance as `this.db`.
|
|
3792
|
+
*/
|
|
3793
|
+
remoteDB;
|
|
3291
3794
|
id;
|
|
3292
3795
|
_getCurrentUser;
|
|
3293
3796
|
updateQueue;
|
|
3294
|
-
|
|
3797
|
+
/**
|
|
3798
|
+
* @param id - Course ID
|
|
3799
|
+
* @param userLookup - Async function returning the current user DB
|
|
3800
|
+
* @param localDB - Optional local PouchDB replica for reads. When provided,
|
|
3801
|
+
* `this.db` uses the local replica and `this.remoteDB` stays remote.
|
|
3802
|
+
* The UpdateQueue reads from remote and writes to remote (local `_rev`
|
|
3803
|
+
* values may be stale, so read-modify-write cycles must go through
|
|
3804
|
+
* the remote DB to avoid conflicts).
|
|
3805
|
+
*/
|
|
3806
|
+
constructor(id, userLookup, localDB) {
|
|
3295
3807
|
this.id = id;
|
|
3296
|
-
|
|
3808
|
+
const remote = getCourseDB2(this.id);
|
|
3809
|
+
this.remoteDB = remote;
|
|
3810
|
+
this.db = localDB ?? remote;
|
|
3297
3811
|
this._getCurrentUser = userLookup;
|
|
3298
|
-
this.updateQueue = new UpdateQueue(this.
|
|
3812
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3299
3813
|
}
|
|
3300
3814
|
getCourseID() {
|
|
3301
3815
|
return this.id;
|
|
@@ -3383,7 +3897,7 @@ var init_courseDB = __esm({
|
|
|
3383
3897
|
};
|
|
3384
3898
|
}
|
|
3385
3899
|
async removeCard(id) {
|
|
3386
|
-
const doc = await this.
|
|
3900
|
+
const doc = await this.remoteDB.get(id);
|
|
3387
3901
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3388
3902
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3389
3903
|
}
|
|
@@ -3404,7 +3918,7 @@ var init_courseDB = __esm({
|
|
|
3404
3918
|
} catch (error) {
|
|
3405
3919
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3406
3920
|
}
|
|
3407
|
-
return this.
|
|
3921
|
+
return this.remoteDB.remove(doc);
|
|
3408
3922
|
}
|
|
3409
3923
|
async getCardDisplayableDataIDs(id) {
|
|
3410
3924
|
logger.debug(id.join(", "));
|
|
@@ -3506,8 +4020,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3506
4020
|
if (cardIds.length === 0) {
|
|
3507
4021
|
return /* @__PURE__ */ new Map();
|
|
3508
4022
|
}
|
|
3509
|
-
const
|
|
3510
|
-
const result = await db.query("getTags", {
|
|
4023
|
+
const result = await this.db.query("getTags", {
|
|
3511
4024
|
keys: cardIds,
|
|
3512
4025
|
include_docs: false
|
|
3513
4026
|
});
|
|
@@ -3524,6 +4037,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3524
4037
|
}
|
|
3525
4038
|
return tagsByCard;
|
|
3526
4039
|
}
|
|
4040
|
+
async getAllCardIds() {
|
|
4041
|
+
const result = await this.db.allDocs({
|
|
4042
|
+
startkey: "CARD-",
|
|
4043
|
+
endkey: "CARD-\uFFF0",
|
|
4044
|
+
include_docs: false
|
|
4045
|
+
});
|
|
4046
|
+
return result.rows.map((row) => row.id);
|
|
4047
|
+
}
|
|
3527
4048
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3528
4049
|
return await addTagToCard(
|
|
3529
4050
|
this.id,
|
|
@@ -3590,10 +4111,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3590
4111
|
}
|
|
3591
4112
|
}
|
|
3592
4113
|
async getCourseDoc(id, options) {
|
|
3593
|
-
return await
|
|
4114
|
+
return await this.db.get(id, options);
|
|
3594
4115
|
}
|
|
3595
4116
|
async getCourseDocs(ids, options = {}) {
|
|
3596
|
-
return await
|
|
4117
|
+
return await this.db.allDocs({
|
|
4118
|
+
...options,
|
|
4119
|
+
keys: ids
|
|
4120
|
+
});
|
|
3597
4121
|
}
|
|
3598
4122
|
////////////////////////////////////
|
|
3599
4123
|
// NavigationStrategyManager implementation
|
|
@@ -3627,7 +4151,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3627
4151
|
}
|
|
3628
4152
|
async addNavigationStrategy(data) {
|
|
3629
4153
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
3630
|
-
return this.
|
|
4154
|
+
return this.remoteDB.put(data).then(() => {
|
|
3631
4155
|
});
|
|
3632
4156
|
}
|
|
3633
4157
|
updateNavigationStrategy(id, data) {
|
|
@@ -5218,6 +5742,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5218
5742
|
const id = row.id;
|
|
5219
5743
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5220
5744
|
id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
|
|
5745
|
+
id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
|
|
5746
|
+
id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
|
|
5747
|
+
id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
|
|
5221
5748
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5222
5749
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5223
5750
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6095,6 +6622,234 @@ var init_adminDB2 = __esm({
|
|
|
6095
6622
|
}
|
|
6096
6623
|
});
|
|
6097
6624
|
|
|
6625
|
+
// src/impl/couch/CourseSyncService.ts
|
|
6626
|
+
var CourseSyncService;
|
|
6627
|
+
var init_CourseSyncService = __esm({
|
|
6628
|
+
"src/impl/couch/CourseSyncService.ts"() {
|
|
6629
|
+
"use strict";
|
|
6630
|
+
init_pouchdb_setup();
|
|
6631
|
+
init_couch();
|
|
6632
|
+
init_logger();
|
|
6633
|
+
CourseSyncService = class _CourseSyncService {
|
|
6634
|
+
static instance = null;
|
|
6635
|
+
entries = /* @__PURE__ */ new Map();
|
|
6636
|
+
constructor() {
|
|
6637
|
+
}
|
|
6638
|
+
static getInstance() {
|
|
6639
|
+
if (!_CourseSyncService.instance) {
|
|
6640
|
+
_CourseSyncService.instance = new _CourseSyncService();
|
|
6641
|
+
}
|
|
6642
|
+
return _CourseSyncService.instance;
|
|
6643
|
+
}
|
|
6644
|
+
/**
|
|
6645
|
+
* Reset the singleton (for testing).
|
|
6646
|
+
*/
|
|
6647
|
+
static resetInstance() {
|
|
6648
|
+
if (_CourseSyncService.instance) {
|
|
6649
|
+
for (const [, entry] of _CourseSyncService.instance.entries) {
|
|
6650
|
+
if (entry.localDB) {
|
|
6651
|
+
entry.localDB.close().catch(() => {
|
|
6652
|
+
});
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
_CourseSyncService.instance.entries.clear();
|
|
6656
|
+
}
|
|
6657
|
+
_CourseSyncService.instance = null;
|
|
6658
|
+
}
|
|
6659
|
+
// --------------------------------------------------------------------------
|
|
6660
|
+
// Public API
|
|
6661
|
+
// --------------------------------------------------------------------------
|
|
6662
|
+
/**
|
|
6663
|
+
* Ensure a course's local replica is synced.
|
|
6664
|
+
*
|
|
6665
|
+
* On first call for a course:
|
|
6666
|
+
* 1. Fetches CourseConfig from remote to check localSync.enabled
|
|
6667
|
+
* 2. If enabled, performs one-shot replication remote → local
|
|
6668
|
+
* 3. Pre-warms PouchDB view indices (elo, getTags)
|
|
6669
|
+
*
|
|
6670
|
+
* On subsequent calls: returns immediately if already synced, or awaits
|
|
6671
|
+
* the in-flight sync if one is in progress.
|
|
6672
|
+
*
|
|
6673
|
+
* Safe to call multiple times — concurrent calls coalesce to one sync.
|
|
6674
|
+
*
|
|
6675
|
+
* @param courseId - The course to sync
|
|
6676
|
+
* @param forceEnabled - Skip the CourseConfig check and sync regardless.
|
|
6677
|
+
* Useful when the caller already knows local sync is desired (e.g.,
|
|
6678
|
+
* LettersPractice hardcodes this).
|
|
6679
|
+
*/
|
|
6680
|
+
async ensureSynced(courseId, forceEnabled) {
|
|
6681
|
+
const existing = this.entries.get(courseId);
|
|
6682
|
+
if (existing?.status.state === "ready") {
|
|
6683
|
+
return;
|
|
6684
|
+
}
|
|
6685
|
+
if (existing?.status.state === "disabled") {
|
|
6686
|
+
return;
|
|
6687
|
+
}
|
|
6688
|
+
if (existing?.readyPromise) {
|
|
6689
|
+
return existing.readyPromise;
|
|
6690
|
+
}
|
|
6691
|
+
const entry = {
|
|
6692
|
+
localDB: null,
|
|
6693
|
+
status: { state: "not-started" },
|
|
6694
|
+
readyPromise: null
|
|
6695
|
+
};
|
|
6696
|
+
this.entries.set(courseId, entry);
|
|
6697
|
+
entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
|
|
6698
|
+
return entry.readyPromise;
|
|
6699
|
+
}
|
|
6700
|
+
/**
|
|
6701
|
+
* Get the local PouchDB for a course, or null if not available.
|
|
6702
|
+
*
|
|
6703
|
+
* Returns null when:
|
|
6704
|
+
* - Local sync is not enabled for this course
|
|
6705
|
+
* - Sync has not been triggered yet
|
|
6706
|
+
* - Sync is still in progress
|
|
6707
|
+
* - Sync failed
|
|
6708
|
+
*/
|
|
6709
|
+
getLocalDB(courseId) {
|
|
6710
|
+
const entry = this.entries.get(courseId);
|
|
6711
|
+
if (entry?.status.state === "ready" && entry.localDB) {
|
|
6712
|
+
return entry.localDB;
|
|
6713
|
+
}
|
|
6714
|
+
return null;
|
|
6715
|
+
}
|
|
6716
|
+
/**
|
|
6717
|
+
* Check whether a course has a ready local replica.
|
|
6718
|
+
*/
|
|
6719
|
+
isReady(courseId) {
|
|
6720
|
+
return this.entries.get(courseId)?.status.state === "ready";
|
|
6721
|
+
}
|
|
6722
|
+
/**
|
|
6723
|
+
* Get detailed sync status for a course.
|
|
6724
|
+
*/
|
|
6725
|
+
getStatus(courseId) {
|
|
6726
|
+
return this.entries.get(courseId)?.status ?? { state: "not-started" };
|
|
6727
|
+
}
|
|
6728
|
+
// --------------------------------------------------------------------------
|
|
6729
|
+
// Internal
|
|
6730
|
+
// --------------------------------------------------------------------------
|
|
6731
|
+
async performSync(courseId, entry, forceEnabled) {
|
|
6732
|
+
try {
|
|
6733
|
+
if (!forceEnabled) {
|
|
6734
|
+
entry.status = { state: "checking-config" };
|
|
6735
|
+
const enabled = await this.checkLocalSyncEnabled(courseId);
|
|
6736
|
+
if (!enabled) {
|
|
6737
|
+
entry.status = { state: "disabled" };
|
|
6738
|
+
entry.readyPromise = null;
|
|
6739
|
+
logger.debug(
|
|
6740
|
+
`[CourseSyncService] Local sync disabled for course ${courseId}`
|
|
6741
|
+
);
|
|
6742
|
+
return;
|
|
6743
|
+
}
|
|
6744
|
+
}
|
|
6745
|
+
entry.status = { state: "syncing" };
|
|
6746
|
+
const localDBName = this.localDBName(courseId);
|
|
6747
|
+
const localDB = new pouchdb_setup_default(localDBName);
|
|
6748
|
+
entry.localDB = localDB;
|
|
6749
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6750
|
+
const syncStart = Date.now();
|
|
6751
|
+
logger.info(
|
|
6752
|
+
`[CourseSyncService] Starting one-shot replication for course ${courseId}`
|
|
6753
|
+
);
|
|
6754
|
+
const result = await this.replicate(remoteDB, localDB);
|
|
6755
|
+
const syncTimeMs = Date.now() - syncStart;
|
|
6756
|
+
logger.info(
|
|
6757
|
+
`[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
|
|
6758
|
+
);
|
|
6759
|
+
entry.status = { state: "warming-views" };
|
|
6760
|
+
const warmStart = Date.now();
|
|
6761
|
+
await this.warmViewIndices(localDB);
|
|
6762
|
+
const viewWarmTimeMs = Date.now() - warmStart;
|
|
6763
|
+
logger.info(
|
|
6764
|
+
`[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
|
|
6765
|
+
);
|
|
6766
|
+
entry.status = {
|
|
6767
|
+
state: "ready",
|
|
6768
|
+
docsReplicated: result.docs_written,
|
|
6769
|
+
syncTimeMs,
|
|
6770
|
+
viewWarmTimeMs
|
|
6771
|
+
};
|
|
6772
|
+
} catch (e) {
|
|
6773
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
6774
|
+
logger.error(
|
|
6775
|
+
`[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
|
|
6776
|
+
);
|
|
6777
|
+
entry.status = { state: "error", error: errorMsg };
|
|
6778
|
+
entry.readyPromise = null;
|
|
6779
|
+
if (entry.localDB) {
|
|
6780
|
+
try {
|
|
6781
|
+
await entry.localDB.destroy();
|
|
6782
|
+
} catch {
|
|
6783
|
+
}
|
|
6784
|
+
entry.localDB = null;
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
}
|
|
6788
|
+
/**
|
|
6789
|
+
* Check CourseConfig.localSync.enabled on the remote DB.
|
|
6790
|
+
*/
|
|
6791
|
+
async checkLocalSyncEnabled(courseId) {
|
|
6792
|
+
try {
|
|
6793
|
+
const remoteDB = this.getRemoteDB(courseId);
|
|
6794
|
+
const config = await remoteDB.get("CourseConfig");
|
|
6795
|
+
return config.localSync?.enabled === true;
|
|
6796
|
+
} catch (e) {
|
|
6797
|
+
logger.warn(
|
|
6798
|
+
`[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
|
|
6799
|
+
);
|
|
6800
|
+
return false;
|
|
6801
|
+
}
|
|
6802
|
+
}
|
|
6803
|
+
/**
|
|
6804
|
+
* One-shot replication from remote to local.
|
|
6805
|
+
*/
|
|
6806
|
+
replicate(source, target) {
|
|
6807
|
+
return new Promise((resolve, reject) => {
|
|
6808
|
+
void pouchdb_setup_default.replicate(source, target, {
|
|
6809
|
+
// One-shot, not live. Local is a read-only snapshot.
|
|
6810
|
+
}).on("complete", (info) => {
|
|
6811
|
+
resolve(info);
|
|
6812
|
+
}).on("error", (err) => {
|
|
6813
|
+
reject(err);
|
|
6814
|
+
});
|
|
6815
|
+
});
|
|
6816
|
+
}
|
|
6817
|
+
/**
|
|
6818
|
+
* Pre-warm PouchDB view indices by running a minimal query against each
|
|
6819
|
+
* design doc. This forces PouchDB to build the MapReduce index now
|
|
6820
|
+
* (during a loading phase) rather than on first pipeline query.
|
|
6821
|
+
*/
|
|
6822
|
+
async warmViewIndices(localDB) {
|
|
6823
|
+
const viewsToWarm = ["elo", "getTags"];
|
|
6824
|
+
for (const viewName of viewsToWarm) {
|
|
6825
|
+
try {
|
|
6826
|
+
await localDB.query(viewName, { limit: 1 });
|
|
6827
|
+
logger.debug(
|
|
6828
|
+
`[CourseSyncService] Warmed view index: ${viewName}`
|
|
6829
|
+
);
|
|
6830
|
+
} catch (e) {
|
|
6831
|
+
logger.debug(
|
|
6832
|
+
`[CourseSyncService] Could not warm view ${viewName}: ${e}`
|
|
6833
|
+
);
|
|
6834
|
+
}
|
|
6835
|
+
}
|
|
6836
|
+
}
|
|
6837
|
+
/**
|
|
6838
|
+
* Get a remote PouchDB handle for a course.
|
|
6839
|
+
*/
|
|
6840
|
+
getRemoteDB(courseId) {
|
|
6841
|
+
return getCourseDB2(courseId);
|
|
6842
|
+
}
|
|
6843
|
+
/**
|
|
6844
|
+
* Local DB naming convention.
|
|
6845
|
+
*/
|
|
6846
|
+
localDBName(courseId) {
|
|
6847
|
+
return `coursedb-local-${courseId}`;
|
|
6848
|
+
}
|
|
6849
|
+
};
|
|
6850
|
+
}
|
|
6851
|
+
});
|
|
6852
|
+
|
|
6098
6853
|
// src/impl/couch/auth.ts
|
|
6099
6854
|
import fetch from "cross-fetch";
|
|
6100
6855
|
async function getCurrentSession() {
|
|
@@ -6537,6 +7292,7 @@ var init_couch = __esm({
|
|
|
6537
7292
|
init_classroomDB2();
|
|
6538
7293
|
init_courseAPI();
|
|
6539
7294
|
init_courseDB();
|
|
7295
|
+
init_CourseSyncService();
|
|
6540
7296
|
init_CouchDBSyncStrategy();
|
|
6541
7297
|
isBrowser = typeof window !== "undefined";
|
|
6542
7298
|
if (isBrowser) {
|
|
@@ -6561,6 +7317,7 @@ export {
|
|
|
6561
7317
|
ClassroomLookupDB,
|
|
6562
7318
|
CouchDBSyncStrategy,
|
|
6563
7319
|
CourseDB,
|
|
7320
|
+
CourseSyncService,
|
|
6564
7321
|
CoursesDB,
|
|
6565
7322
|
REVIEW_TIME_FORMAT,
|
|
6566
7323
|
StudentClassroomDB,
|