@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
package/dist/impl/couch/index.js
CHANGED
|
@@ -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,11 +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";
|
|
745
|
+
init_navigators();
|
|
740
746
|
init_logger();
|
|
747
|
+
_activePipeline = null;
|
|
741
748
|
MAX_RUNS = 10;
|
|
742
749
|
runHistory = [];
|
|
743
750
|
pipelineDebugAPI = {
|
|
@@ -879,6 +886,81 @@ var init_PipelineDebugger = __esm({
|
|
|
879
886
|
runHistory.length = 0;
|
|
880
887
|
logger.info("[Pipeline Debug] Run history cleared.");
|
|
881
888
|
},
|
|
889
|
+
/**
|
|
890
|
+
* Show the navigator registry: all registered classes and their roles.
|
|
891
|
+
*
|
|
892
|
+
* Useful for verifying that consumer-defined navigators were registered
|
|
893
|
+
* before pipeline assembly.
|
|
894
|
+
*/
|
|
895
|
+
showRegistry() {
|
|
896
|
+
const names = getRegisteredNavigatorNames();
|
|
897
|
+
if (names.length === 0) {
|
|
898
|
+
logger.info("[Pipeline Debug] Navigator registry is empty.");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
console.group("\u{1F4E6} Navigator Registry");
|
|
902
|
+
console.table(
|
|
903
|
+
names.map((name) => {
|
|
904
|
+
const registryRole = getRegisteredNavigatorRole(name);
|
|
905
|
+
const builtinRole = NavigatorRoles[name];
|
|
906
|
+
const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
|
|
907
|
+
const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
|
|
908
|
+
return {
|
|
909
|
+
name,
|
|
910
|
+
role: effectiveRole,
|
|
911
|
+
source,
|
|
912
|
+
isGenerator: isGenerator(name),
|
|
913
|
+
isFilter: isFilter(name)
|
|
914
|
+
};
|
|
915
|
+
})
|
|
916
|
+
);
|
|
917
|
+
console.groupEnd();
|
|
918
|
+
},
|
|
919
|
+
/**
|
|
920
|
+
* Show strategy documents from the last pipeline run and how they mapped
|
|
921
|
+
* to the registry.
|
|
922
|
+
*
|
|
923
|
+
* If no runs are captured yet, falls back to showing just the registry.
|
|
924
|
+
*/
|
|
925
|
+
showStrategies() {
|
|
926
|
+
this.showRegistry();
|
|
927
|
+
if (runHistory.length === 0) {
|
|
928
|
+
logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const run = runHistory[0];
|
|
932
|
+
console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
|
|
933
|
+
logger.info(`Generator: ${run.generatorName}`);
|
|
934
|
+
if (run.generators && run.generators.length > 0) {
|
|
935
|
+
for (const g of run.generators) {
|
|
936
|
+
logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (run.filters.length > 0) {
|
|
940
|
+
logger.info("Filters:");
|
|
941
|
+
for (const f of run.filters) {
|
|
942
|
+
logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
logger.info("Filters: (none)");
|
|
946
|
+
}
|
|
947
|
+
console.groupEnd();
|
|
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
|
+
},
|
|
882
964
|
/**
|
|
883
965
|
* Show help.
|
|
884
966
|
*/
|
|
@@ -891,6 +973,9 @@ Commands:
|
|
|
891
973
|
.showRun(id|index) Show summary of a specific run (by index or ID suffix)
|
|
892
974
|
.showCard(cardId) Show provenance trail for a specific card
|
|
893
975
|
.explainReviews() Analyze why reviews were/weren't selected
|
|
976
|
+
.diagnoseCardSpace() Scan full card space through filters (async)
|
|
977
|
+
.showRegistry() Show navigator registry (classes + roles)
|
|
978
|
+
.showStrategies() Show registry + strategy mapping from last run
|
|
894
979
|
.listRuns() List all captured runs in table format
|
|
895
980
|
.export() Export run history as JSON for bug reports
|
|
896
981
|
.clear() Clear run history
|
|
@@ -900,7 +985,7 @@ Commands:
|
|
|
900
985
|
Example:
|
|
901
986
|
window.skuilder.pipeline.showLastRun()
|
|
902
987
|
window.skuilder.pipeline.showRun(1)
|
|
903
|
-
window.skuilder.pipeline.
|
|
988
|
+
await window.skuilder.pipeline.diagnoseCardSpace()
|
|
904
989
|
`);
|
|
905
990
|
}
|
|
906
991
|
};
|
|
@@ -1195,6 +1280,69 @@ var init_generators = __esm({
|
|
|
1195
1280
|
}
|
|
1196
1281
|
});
|
|
1197
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
|
+
|
|
1198
1346
|
// src/core/navigators/generators/srs.ts
|
|
1199
1347
|
var srs_exports = {};
|
|
1200
1348
|
__export(srs_exports, {
|
|
@@ -1389,6 +1537,7 @@ var init_ = __esm({
|
|
|
1389
1537
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
1390
1538
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
1391
1539
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
1540
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
1392
1541
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
1393
1542
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
|
|
1394
1543
|
});
|
|
@@ -1589,6 +1738,8 @@ var init_hierarchyDefinition = __esm({
|
|
|
1589
1738
|
if (userTagElo.count < minCount) return false;
|
|
1590
1739
|
if (prereq.masteryThreshold?.minElo !== void 0) {
|
|
1591
1740
|
return userTagElo.score >= prereq.masteryThreshold.minElo;
|
|
1741
|
+
} else if (prereq.masteryThreshold?.minCount !== void 0) {
|
|
1742
|
+
return true;
|
|
1592
1743
|
} else {
|
|
1593
1744
|
return userTagElo.score >= userGlobalElo;
|
|
1594
1745
|
}
|
|
@@ -1664,14 +1815,38 @@ var init_hierarchyDefinition = __esm({
|
|
|
1664
1815
|
};
|
|
1665
1816
|
}
|
|
1666
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
|
+
}
|
|
1667
1838
|
/**
|
|
1668
1839
|
* CardFilter.transform implementation.
|
|
1669
1840
|
*
|
|
1670
|
-
*
|
|
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
|
|
1671
1845
|
*/
|
|
1672
1846
|
async transform(cards, context) {
|
|
1673
1847
|
const masteredTags = await this.getMasteredTags(context);
|
|
1674
1848
|
const unlockedTags = this.getUnlockedTags(masteredTags);
|
|
1849
|
+
const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
|
|
1675
1850
|
const gated = [];
|
|
1676
1851
|
for (const card of cards) {
|
|
1677
1852
|
const { isUnlocked, reason } = await this.checkCardUnlock(
|
|
@@ -1680,9 +1855,27 @@ var init_hierarchyDefinition = __esm({
|
|
|
1680
1855
|
unlockedTags,
|
|
1681
1856
|
masteredTags
|
|
1682
1857
|
);
|
|
1683
|
-
const LOCKED_PENALTY = 0.
|
|
1684
|
-
|
|
1685
|
-
|
|
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
|
+
}
|
|
1686
1879
|
gated.push({
|
|
1687
1880
|
...card,
|
|
1688
1881
|
score: finalScore,
|
|
@@ -1694,7 +1887,7 @@ var init_hierarchyDefinition = __esm({
|
|
|
1694
1887
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
|
|
1695
1888
|
action,
|
|
1696
1889
|
score: finalScore,
|
|
1697
|
-
reason
|
|
1890
|
+
reason: finalReason
|
|
1698
1891
|
}
|
|
1699
1892
|
]
|
|
1700
1893
|
});
|
|
@@ -2382,6 +2575,18 @@ var Pipeline_exports = {};
|
|
|
2382
2575
|
__export(Pipeline_exports, {
|
|
2383
2576
|
Pipeline: () => Pipeline
|
|
2384
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
|
+
}
|
|
2385
2590
|
function logPipelineConfig(generator, filters) {
|
|
2386
2591
|
const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
|
|
2387
2592
|
logger.info(
|
|
@@ -2416,6 +2621,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
|
|
|
2416
2621
|
\u{1F4A1} Inspect: window.skuilder.pipeline`
|
|
2417
2622
|
);
|
|
2418
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
|
+
}
|
|
2419
2639
|
function logCardProvenance(cards, maxCards = 3) {
|
|
2420
2640
|
const cardsToLog = cards.slice(0, maxCards);
|
|
2421
2641
|
logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
|
|
@@ -2430,7 +2650,7 @@ function logCardProvenance(cards, maxCards = 3) {
|
|
|
2430
2650
|
}
|
|
2431
2651
|
}
|
|
2432
2652
|
}
|
|
2433
|
-
var import_common8, Pipeline;
|
|
2653
|
+
var import_common8, VERBOSE_RESULTS, Pipeline;
|
|
2434
2654
|
var init_Pipeline = __esm({
|
|
2435
2655
|
"src/core/navigators/Pipeline.ts"() {
|
|
2436
2656
|
"use strict";
|
|
@@ -2439,9 +2659,31 @@ var init_Pipeline = __esm({
|
|
|
2439
2659
|
init_logger();
|
|
2440
2660
|
init_orchestration();
|
|
2441
2661
|
init_PipelineDebugger();
|
|
2662
|
+
VERBOSE_RESULTS = true;
|
|
2442
2663
|
Pipeline = class extends ContentNavigator {
|
|
2443
2664
|
generator;
|
|
2444
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;
|
|
2445
2687
|
/**
|
|
2446
2688
|
* Create a new pipeline.
|
|
2447
2689
|
*
|
|
@@ -2462,6 +2704,17 @@ var init_Pipeline = __esm({
|
|
|
2462
2704
|
logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
|
|
2463
2705
|
});
|
|
2464
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)}`);
|
|
2465
2718
|
}
|
|
2466
2719
|
/**
|
|
2467
2720
|
* Get weighted cards by running generator and applying filters.
|
|
@@ -2478,13 +2731,15 @@ var init_Pipeline = __esm({
|
|
|
2478
2731
|
* @returns Cards sorted by score descending
|
|
2479
2732
|
*/
|
|
2480
2733
|
async getWeightedCards(limit) {
|
|
2734
|
+
const t0 = performance.now();
|
|
2481
2735
|
const context = await this.buildContext();
|
|
2482
|
-
const
|
|
2483
|
-
const fetchLimit =
|
|
2736
|
+
const tContext = performance.now();
|
|
2737
|
+
const fetchLimit = 500;
|
|
2484
2738
|
logger.debug(
|
|
2485
2739
|
`[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
|
|
2486
2740
|
);
|
|
2487
2741
|
let cards = await this.generator.getWeightedCards(fetchLimit, context);
|
|
2742
|
+
const tGenerate = performance.now();
|
|
2488
2743
|
const generatedCount = cards.length;
|
|
2489
2744
|
let generatorSummaries;
|
|
2490
2745
|
if (this.generator.generators) {
|
|
@@ -2513,6 +2768,7 @@ var init_Pipeline = __esm({
|
|
|
2513
2768
|
}
|
|
2514
2769
|
logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
|
|
2515
2770
|
cards = await this.hydrateTags(cards);
|
|
2771
|
+
const tHydrate = performance.now();
|
|
2516
2772
|
const allCardsBeforeFiltering = [...cards];
|
|
2517
2773
|
const filterImpacts = [];
|
|
2518
2774
|
for (const filter of this.filters) {
|
|
@@ -2531,8 +2787,17 @@ var init_Pipeline = __esm({
|
|
|
2531
2787
|
logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
|
|
2532
2788
|
}
|
|
2533
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
|
+
}
|
|
2534
2795
|
cards.sort((a, b) => b.score - a.score);
|
|
2796
|
+
const tFilter = performance.now();
|
|
2535
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
|
+
);
|
|
2536
2801
|
const topScores = result.slice(0, 3).map((c) => c.score);
|
|
2537
2802
|
logExecutionSummary(
|
|
2538
2803
|
this.generator.name,
|
|
@@ -2542,6 +2807,7 @@ var init_Pipeline = __esm({
|
|
|
2542
2807
|
topScores,
|
|
2543
2808
|
filterImpacts
|
|
2544
2809
|
);
|
|
2810
|
+
logResultCards(result);
|
|
2545
2811
|
logCardProvenance(result, 3);
|
|
2546
2812
|
try {
|
|
2547
2813
|
const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
|
|
@@ -2568,6 +2834,10 @@ var init_Pipeline = __esm({
|
|
|
2568
2834
|
* to the WeightedCard objects. Filters can then use card.tags instead of
|
|
2569
2835
|
* making individual getAppliedTags() calls.
|
|
2570
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
|
+
*
|
|
2571
2841
|
* @param cards - Cards to hydrate
|
|
2572
2842
|
* @returns Cards with tags populated
|
|
2573
2843
|
*/
|
|
@@ -2575,14 +2845,128 @@ var init_Pipeline = __esm({
|
|
|
2575
2845
|
if (cards.length === 0) {
|
|
2576
2846
|
return cards;
|
|
2577
2847
|
}
|
|
2578
|
-
const
|
|
2579
|
-
const
|
|
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
|
+
}
|
|
2580
2864
|
logTagHydration(cards, tagsByCard);
|
|
2581
2865
|
return cards.map((card) => ({
|
|
2582
2866
|
...card,
|
|
2583
|
-
tags:
|
|
2867
|
+
tags: this._tagCache.get(card.cardId) ?? []
|
|
2584
2868
|
}));
|
|
2585
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
|
+
}
|
|
2586
2970
|
/**
|
|
2587
2971
|
* Build shared context for generator and filters.
|
|
2588
2972
|
*
|
|
@@ -2600,7 +2984,10 @@ var init_Pipeline = __esm({
|
|
|
2600
2984
|
} catch (e) {
|
|
2601
2985
|
logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
|
|
2602
2986
|
}
|
|
2603
|
-
|
|
2987
|
+
if (!this._cachedOrchestration) {
|
|
2988
|
+
this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
|
|
2989
|
+
}
|
|
2990
|
+
const orchestration = this._cachedOrchestration;
|
|
2604
2991
|
return {
|
|
2605
2992
|
user: this.user,
|
|
2606
2993
|
course: this.course,
|
|
@@ -2644,6 +3031,87 @@ var init_Pipeline = __esm({
|
|
|
2644
3031
|
}
|
|
2645
3032
|
return [...new Set(ids)];
|
|
2646
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
|
+
}
|
|
2647
3115
|
};
|
|
2648
3116
|
}
|
|
2649
3117
|
});
|
|
@@ -2748,23 +3216,25 @@ var init_PipelineAssembler = __esm({
|
|
|
2748
3216
|
warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
|
|
2749
3217
|
}
|
|
2750
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
|
+
}
|
|
2751
3230
|
if (generatorStrategies.length === 0) {
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
} else {
|
|
2760
|
-
warnings.push("No generator strategy found");
|
|
2761
|
-
return {
|
|
2762
|
-
pipeline: null,
|
|
2763
|
-
generatorStrategies: [],
|
|
2764
|
-
filterStrategies: [],
|
|
2765
|
-
warnings
|
|
2766
|
-
};
|
|
2767
|
-
}
|
|
3231
|
+
warnings.push("No generator strategy found");
|
|
3232
|
+
return {
|
|
3233
|
+
pipeline: null,
|
|
3234
|
+
generatorStrategies: [],
|
|
3235
|
+
filterStrategies: [],
|
|
3236
|
+
warnings
|
|
3237
|
+
};
|
|
2768
3238
|
}
|
|
2769
3239
|
let generator;
|
|
2770
3240
|
if (generatorStrategies.length === 1) {
|
|
@@ -2842,6 +3312,7 @@ var init_3 = __esm({
|
|
|
2842
3312
|
"./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
|
|
2843
3313
|
"./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2844
3314
|
"./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
|
|
3315
|
+
"./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
|
|
2845
3316
|
"./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
|
|
2846
3317
|
"./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
|
|
2847
3318
|
"./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
|
|
@@ -2859,6 +3330,7 @@ __export(navigators_exports, {
|
|
|
2859
3330
|
getCardOrigin: () => getCardOrigin,
|
|
2860
3331
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
2861
3332
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
3333
|
+
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
2862
3334
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
2863
3335
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
2864
3336
|
isFilter: () => isFilter,
|
|
@@ -2867,16 +3339,19 @@ __export(navigators_exports, {
|
|
|
2867
3339
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
2868
3340
|
registerNavigator: () => registerNavigator
|
|
2869
3341
|
});
|
|
2870
|
-
function registerNavigator(implementingClass, constructor) {
|
|
2871
|
-
navigatorRegistry.set(implementingClass, constructor);
|
|
2872
|
-
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
|
|
3342
|
+
function registerNavigator(implementingClass, constructor, role) {
|
|
3343
|
+
navigatorRegistry.set(implementingClass, { constructor, role });
|
|
3344
|
+
logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
|
|
2873
3345
|
}
|
|
2874
3346
|
function getRegisteredNavigator(implementingClass) {
|
|
2875
|
-
return navigatorRegistry.get(implementingClass);
|
|
3347
|
+
return navigatorRegistry.get(implementingClass)?.constructor;
|
|
2876
3348
|
}
|
|
2877
3349
|
function hasRegisteredNavigator(implementingClass) {
|
|
2878
3350
|
return navigatorRegistry.has(implementingClass);
|
|
2879
3351
|
}
|
|
3352
|
+
function getRegisteredNavigatorRole(implementingClass) {
|
|
3353
|
+
return navigatorRegistry.get(implementingClass)?.role;
|
|
3354
|
+
}
|
|
2880
3355
|
function getRegisteredNavigatorNames() {
|
|
2881
3356
|
return Array.from(navigatorRegistry.keys());
|
|
2882
3357
|
}
|
|
@@ -2886,8 +3361,10 @@ async function initializeNavigatorRegistry() {
|
|
|
2886
3361
|
Promise.resolve().then(() => (init_elo(), elo_exports)),
|
|
2887
3362
|
Promise.resolve().then(() => (init_srs(), srs_exports))
|
|
2888
3363
|
]);
|
|
3364
|
+
const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
|
|
2889
3365
|
registerNavigator("elo", eloModule.default);
|
|
2890
3366
|
registerNavigator("srs", srsModule.default);
|
|
3367
|
+
registerNavigator("prescribed", prescribedModule.default);
|
|
2891
3368
|
const [
|
|
2892
3369
|
hierarchyModule,
|
|
2893
3370
|
interferenceModule,
|
|
@@ -2922,10 +3399,12 @@ function getCardOrigin(card) {
|
|
|
2922
3399
|
return "new";
|
|
2923
3400
|
}
|
|
2924
3401
|
function isGenerator(impl) {
|
|
2925
|
-
|
|
3402
|
+
if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
|
|
3403
|
+
return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
|
|
2926
3404
|
}
|
|
2927
3405
|
function isFilter(impl) {
|
|
2928
|
-
|
|
3406
|
+
if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
|
|
3407
|
+
return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
|
|
2929
3408
|
}
|
|
2930
3409
|
var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
|
|
2931
3410
|
var init_navigators = __esm({
|
|
@@ -2940,6 +3419,7 @@ var init_navigators = __esm({
|
|
|
2940
3419
|
Navigators = /* @__PURE__ */ ((Navigators2) => {
|
|
2941
3420
|
Navigators2["ELO"] = "elo";
|
|
2942
3421
|
Navigators2["SRS"] = "srs";
|
|
3422
|
+
Navigators2["PRESCRIBED"] = "prescribed";
|
|
2943
3423
|
Navigators2["HIERARCHY"] = "hierarchyDefinition";
|
|
2944
3424
|
Navigators2["INTERFERENCE"] = "interferenceMitigator";
|
|
2945
3425
|
Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
|
|
@@ -2954,6 +3434,7 @@ var init_navigators = __esm({
|
|
|
2954
3434
|
NavigatorRoles = {
|
|
2955
3435
|
["elo" /* ELO */]: "generator" /* GENERATOR */,
|
|
2956
3436
|
["srs" /* SRS */]: "generator" /* GENERATOR */,
|
|
3437
|
+
["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
|
|
2957
3438
|
["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
|
|
2958
3439
|
["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
|
|
2959
3440
|
["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
|
|
@@ -3118,6 +3599,12 @@ var init_navigators = __esm({
|
|
|
3118
3599
|
async getWeightedCards(_limit) {
|
|
3119
3600
|
throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
|
|
3120
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
|
+
}
|
|
3121
3608
|
};
|
|
3122
3609
|
}
|
|
3123
3610
|
});
|
|
@@ -3304,15 +3791,42 @@ var init_courseDB = __esm({
|
|
|
3304
3791
|
// private log(msg: string): void {
|
|
3305
3792
|
// log(`CourseLog: ${this.id}\n ${msg}`);
|
|
3306
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
|
+
*/
|
|
3307
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;
|
|
3308
3811
|
id;
|
|
3309
3812
|
_getCurrentUser;
|
|
3310
3813
|
updateQueue;
|
|
3311
|
-
|
|
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) {
|
|
3312
3824
|
this.id = id;
|
|
3313
|
-
|
|
3825
|
+
const remote = getCourseDB2(this.id);
|
|
3826
|
+
this.remoteDB = remote;
|
|
3827
|
+
this.db = localDB ?? remote;
|
|
3314
3828
|
this._getCurrentUser = userLookup;
|
|
3315
|
-
this.updateQueue = new UpdateQueue(this.
|
|
3829
|
+
this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
|
|
3316
3830
|
}
|
|
3317
3831
|
getCourseID() {
|
|
3318
3832
|
return this.id;
|
|
@@ -3400,7 +3914,7 @@ var init_courseDB = __esm({
|
|
|
3400
3914
|
};
|
|
3401
3915
|
}
|
|
3402
3916
|
async removeCard(id) {
|
|
3403
|
-
const doc = await this.
|
|
3917
|
+
const doc = await this.remoteDB.get(id);
|
|
3404
3918
|
if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
|
|
3405
3919
|
throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
|
|
3406
3920
|
}
|
|
@@ -3421,7 +3935,7 @@ var init_courseDB = __esm({
|
|
|
3421
3935
|
} catch (error) {
|
|
3422
3936
|
logger.error(`Error removing card ${id} from tags: ${error}`);
|
|
3423
3937
|
}
|
|
3424
|
-
return this.
|
|
3938
|
+
return this.remoteDB.remove(doc);
|
|
3425
3939
|
}
|
|
3426
3940
|
async getCardDisplayableDataIDs(id) {
|
|
3427
3941
|
logger.debug(id.join(", "));
|
|
@@ -3523,8 +4037,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3523
4037
|
if (cardIds.length === 0) {
|
|
3524
4038
|
return /* @__PURE__ */ new Map();
|
|
3525
4039
|
}
|
|
3526
|
-
const
|
|
3527
|
-
const result = await db.query("getTags", {
|
|
4040
|
+
const result = await this.db.query("getTags", {
|
|
3528
4041
|
keys: cardIds,
|
|
3529
4042
|
include_docs: false
|
|
3530
4043
|
});
|
|
@@ -3541,6 +4054,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3541
4054
|
}
|
|
3542
4055
|
return tagsByCard;
|
|
3543
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
|
+
}
|
|
3544
4065
|
async addTagToCard(cardId, tagId, updateELO) {
|
|
3545
4066
|
return await addTagToCard(
|
|
3546
4067
|
this.id,
|
|
@@ -3607,10 +4128,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3607
4128
|
}
|
|
3608
4129
|
}
|
|
3609
4130
|
async getCourseDoc(id, options) {
|
|
3610
|
-
return await
|
|
4131
|
+
return await this.db.get(id, options);
|
|
3611
4132
|
}
|
|
3612
4133
|
async getCourseDocs(ids, options = {}) {
|
|
3613
|
-
return await
|
|
4134
|
+
return await this.db.allDocs({
|
|
4135
|
+
...options,
|
|
4136
|
+
keys: ids
|
|
4137
|
+
});
|
|
3614
4138
|
}
|
|
3615
4139
|
////////////////////////////////////
|
|
3616
4140
|
// NavigationStrategyManager implementation
|
|
@@ -3644,7 +4168,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
|
|
|
3644
4168
|
}
|
|
3645
4169
|
async addNavigationStrategy(data) {
|
|
3646
4170
|
logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
|
|
3647
|
-
return this.
|
|
4171
|
+
return this.remoteDB.put(data).then(() => {
|
|
3648
4172
|
});
|
|
3649
4173
|
}
|
|
3650
4174
|
updateNavigationStrategy(id, data) {
|
|
@@ -5238,6 +5762,9 @@ Currently logged-in as ${this._username}.`
|
|
|
5238
5762
|
const id = row.id;
|
|
5239
5763
|
return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
|
|
5240
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
|
|
5241
5768
|
id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
|
|
5242
5769
|
id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
|
|
5243
5770
|
id === _BaseUser.DOC_IDS.CONFIG;
|
|
@@ -6115,6 +6642,234 @@ var init_adminDB2 = __esm({
|
|
|
6115
6642
|
}
|
|
6116
6643
|
});
|
|
6117
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
|
+
|
|
6118
6873
|
// src/impl/couch/auth.ts
|
|
6119
6874
|
async function getCurrentSession() {
|
|
6120
6875
|
try {
|
|
@@ -6391,6 +7146,7 @@ __export(couch_exports, {
|
|
|
6391
7146
|
ClassroomLookupDB: () => ClassroomLookupDB,
|
|
6392
7147
|
CouchDBSyncStrategy: () => CouchDBSyncStrategy,
|
|
6393
7148
|
CourseDB: () => CourseDB,
|
|
7149
|
+
CourseSyncService: () => CourseSyncService,
|
|
6394
7150
|
CoursesDB: () => CoursesDB,
|
|
6395
7151
|
REVIEW_TIME_FORMAT: () => REVIEW_TIME_FORMAT,
|
|
6396
7152
|
StudentClassroomDB: () => StudentClassroomDB,
|
|
@@ -6607,6 +7363,7 @@ var init_couch = __esm({
|
|
|
6607
7363
|
init_classroomDB2();
|
|
6608
7364
|
init_courseAPI();
|
|
6609
7365
|
init_courseDB();
|
|
7366
|
+
init_CourseSyncService();
|
|
6610
7367
|
init_CouchDBSyncStrategy();
|
|
6611
7368
|
isBrowser = typeof window !== "undefined";
|
|
6612
7369
|
if (isBrowser) {
|
|
@@ -6632,6 +7389,7 @@ init_couch();
|
|
|
6632
7389
|
ClassroomLookupDB,
|
|
6633
7390
|
CouchDBSyncStrategy,
|
|
6634
7391
|
CourseDB,
|
|
7392
|
+
CourseSyncService,
|
|
6635
7393
|
CoursesDB,
|
|
6636
7394
|
REVIEW_TIME_FORMAT,
|
|
6637
7395
|
StudentClassroomDB,
|