diff --git a/package-lock.json b/package-lock.json index 8764fd3..d690f0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "@effect/schema": "^0.63.2", "@jest/globals": "^29.7.0", "@types/body-parser": "^1.19.2", + "@types/cls-hooked": "^4.3.9", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.12", "@types/crypto-js": "^4.1.0", @@ -20,6 +21,7 @@ "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "body-parser": "^1.19.1", + "cls-hooked": "^4.2.2", "connect-session-sequelize": "^7.1.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", @@ -1311,6 +1313,14 @@ "@types/node": "*" } }, + "node_modules/@types/cls-hooked": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@types/cls-hooked/-/cls-hooked-4.3.9.tgz", + "integrity": "sha512-CMtHMz6Q/dkfcHarq9nioXH8BDPP+v5xvd+N90lBQ2bdmu06UvnLDqxTKoOJzz4SzIwb/x9i4UXGAAcnUDuIvg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -2107,6 +2117,17 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "node_modules/async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "dependencies": { + "stack-chain": "^1.3.7" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2576,6 +2597,27 @@ "node": ">=12" } }, + "node_modules/cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "dependencies": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3 || >=8.2.1" + } + }, + "node_modules/cls-hooked/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3009,6 +3051,14 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz", "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==" }, + "node_modules/emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "dependencies": { + "shimmer": "^1.2.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -6617,6 +6667,11 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -6692,6 +6747,11 @@ "node": ">= 0.6" } }, + "node_modules/stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==" + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -8520,6 +8580,14 @@ "@types/node": "*" } }, + "@types/cls-hooked": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@types/cls-hooked/-/cls-hooked-4.3.9.tgz", + "integrity": "sha512-CMtHMz6Q/dkfcHarq9nioXH8BDPP+v5xvd+N90lBQ2bdmu06UvnLDqxTKoOJzz4SzIwb/x9i4UXGAAcnUDuIvg==", + "requires": { + "@types/node": "*" + } + }, "@types/connect": { "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", @@ -9176,6 +9244,14 @@ "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" }, + "async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "requires": { + "stack-chain": "^1.3.7" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -9493,6 +9569,23 @@ "wrap-ansi": "^7.0.0" } }, + "cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "requires": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==" + } + } + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9818,6 +9911,14 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.18.tgz", "integrity": "sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==" }, + "emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "requires": { + "shimmer": "^1.2.0" + } + }, "emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -12466,6 +12567,11 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -12526,6 +12632,11 @@ "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==" }, + "stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==" + }, "stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/package.json b/package.json index 57a2835..6ea7e12 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@effect/schema": "^0.63.2", "@jest/globals": "^29.7.0", "@types/body-parser": "^1.19.2", + "@types/cls-hooked": "^4.3.9", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.12", "@types/crypto-js": "^4.1.0", @@ -22,6 +23,7 @@ "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "body-parser": "^1.19.1", + "cls-hooked": "^4.2.2", "connect-session-sequelize": "^7.1.3", "cookie-parser": "^1.4.6", "cors": "^2.8.5", diff --git a/src/database.ts b/src/database.ts index 7ed43ea..3381c34 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,5 +1,6 @@ -import { BaseError, Model, Op, QueryTypes, Sequelize, Transaction, UniqueConstraintError, WhereOptions } from "sequelize"; +import { BaseError, Model, Op, QueryTypes, Sequelize, UniqueConstraintError, WhereOptions } from "sequelize"; import dotenv from "dotenv"; +import { createNamespace } from "cls-hooked"; import * as S from "@effect/schema/Schema"; @@ -41,7 +42,7 @@ import { StudentOption, StudentOptions } from "./models/student_options"; import { Question } from "./models/question"; import { logger } from "./logger"; import { Stage } from "./models/stage"; -import { addClassToMergeGroup } from "./stories/hubbles_law/database"; +import { classSetupRegistry } from "./registries"; export type LoginResponse = { type: "none" | "student" | "educator" | "admin", @@ -93,6 +94,9 @@ export function getDatabaseConnection(options?: DBConnectionOptions) { // Create any associations that we need setUpAssociations(); + const namespace = createNamespace("cds-api-namespace"); + Sequelize.useCLS(namespace); + return database; } @@ -282,7 +286,7 @@ export async function signUpStudent(options: SignUpStudentOptions): Promise { result = signupResultFromError(error); }); @@ -314,6 +318,7 @@ export const CreateClassSchema = S.struct({ name: S.string, expected_size: S.number.pipe(S.int()), asynchronous: S.optional(S.boolean), + story_name: S.optional(S.string), }); export type CreateClassOptions = S.Schema.To; @@ -330,33 +335,28 @@ export async function createClass(options: CreateClassOptions): Promise { + await db.transaction(async _transaction => { - const cls = await Class.create(creationInfo, { transaction }); + const cls = await Class.create(creationInfo); - // For the pilot, the Hubble Data Story will be the only option, - // so we'll automatically associate that with the class - // TODO: When there are more classes available, we need to make - // this functionality more generic - if (cls) { + const storyName = options.story_name; + if (storyName) { await ClassStories.create({ - story_name: "hubbles_law", - class_id: cls.id - }, { transaction }); + story_name: storyName, + class_id: cls.id, + }); + const setupFunctions = classSetupRegistry.setupFunctions(storyName); + if (setupFunctions) { + for (const setupFunc of setupFunctions) { + await setupFunc(cls, storyName); + } + } } return cls; }); - // Another piece of Hubble-specific functionality - // Note that we need to reload the class so that the virtual `small_class` - // column has its value populated - await cls.reload(); - if (cls.asynchronous || cls.small_class) { - await addClassToMergeGroup(cls.id); - } - return { result: result, class: creationInfo }; } catch (error) { result = (error instanceof BaseError) ? createClassResultFromError(error) : CreateClassResult.Error; @@ -687,8 +687,7 @@ export async function getClassRoster(classID: number): Promise { } /** These functions are for testing purposes only */ -export async function newDummyClassForStory(storyName: string, transaction?: Transaction): Promise<{cls: Class, dummy: DummyClass}> { - const trans = transaction ?? null; +export async function newDummyClassForStory(storyName: string): Promise<{cls: Class, dummy: DummyClass}> { const ct = await Class.count({ where: { educator_id: 0, @@ -696,16 +695,14 @@ export async function newDummyClassForStory(storyName: string, transaction?: Tra [Op.like]: `DummyClass_${storyName}_` } }, - transaction: trans, }); const cls = await Class.create({ educator_id: 0, name: `DummyClass_${storyName}_${ct+1}`, code: "xxxxxx" - }, { transaction: trans }); + }); let dc = await DummyClass.findOne({ where: { story_name: storyName }, - transaction: trans, }); if (dc !== null) { dc.update({ class_id: cls.id }) @@ -716,8 +713,8 @@ export async function newDummyClassForStory(storyName: string, transaction?: Tra } else { dc = await DummyClass.create({ class_id: cls.id, - story_name: storyName - }, { transaction: trans }); + story_name: storyName, + }); } return { cls: cls, dummy: dc }; } @@ -738,7 +735,7 @@ export async function newDummyStudent(seed = false, } try { - const transactionResult = await db.transaction(async transaction => { + const transactionResult = await db.transaction(async _transaction => { const student = await Student.create({ username: `dummy_student_${newID}`, verified: 1, @@ -751,25 +748,23 @@ export async function newDummyStudent(seed = false, seed: seed ? 1 : 0, team_member: teamMember, dummy: true - }, { transaction }); + }); // If we have a story name, and are creating a seed student, we want to add this student to the current "dummy class" for that story if (seed && storyName !== null) { let cls: Class | null = null; let dummyClass = await DummyClass.findOne({ where: { story_name: storyName }, - transaction }); let clsSize: number; if (dummyClass === null) { - const res = await newDummyClassForStory(storyName, transaction); + const res = await newDummyClassForStory(storyName); dummyClass = res.dummy; cls = res.cls; clsSize = 0; } else { clsSize = await StudentsClasses.count({ where: { class_id: dummyClass.class_id }, - transaction, }); } @@ -780,14 +775,13 @@ export async function newDummyStudent(seed = false, } else { cls = await Class.findOne({ where: { id: dummyClass.class_id }, - transaction, }); } if (cls !== null) { await StudentsClasses.create({ class_id: cls.id, student_id: student.id - }, { transaction }); + }); } } return student; diff --git a/src/main.ts b/src/main.ts index 6dabb03..0b24d75 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { join } from "path"; import { createApp } from "./server"; import { getDatabaseConnection } from "./database"; + const STORIES_DIR = join(__dirname, "stories"); const MAIN_FILE = "main.js"; diff --git a/src/models/class.ts b/src/models/class.ts index c9b94df..10bb74f 100644 --- a/src/models/class.ts +++ b/src/models/class.ts @@ -69,7 +69,11 @@ export function initializeClassModel(sequelize: Sequelize) { allowNull: false, }, small_class: { - type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", + type: DataTypes.VIRTUAL, + // type: "tinyint(1) GENERATED ALWAYS AS (expected_size < 15) VIRTUAL", + get() { + return this.expected_size < 15; + } }, }, { sequelize, diff --git a/src/models/story.ts b/src/models/story.ts index 041fbd9..5894529 100644 --- a/src/models/story.ts +++ b/src/models/story.ts @@ -4,6 +4,7 @@ export class Story extends Model, InferCreationAttributes declare id: CreationOptional; declare name: string; declare display_name: string; + declare description: CreationOptional; } export function initializeStoryModel(sequelize: Sequelize) { @@ -23,6 +24,10 @@ export function initializeStoryModel(sequelize: Sequelize) { type: DataTypes.STRING, allowNull: false, unique: true + }, + description: { + type: DataTypes.STRING, + allowNull: true, } }, { sequelize, diff --git a/src/registries.ts b/src/registries.ts new file mode 100644 index 0000000..abe42c8 --- /dev/null +++ b/src/registries.ts @@ -0,0 +1,34 @@ +import { Class } from "./models"; + +type ClassSetupFunction = (cls: Class, storyName: string) => Promise; + +class ClassSetupRegistry { + + private members: { [storyName: string]: ClassSetupFunction[] | undefined } = {}; + + register(storyName: string, setup: ClassSetupFunction) { + if (!(storyName in this.members)) { + this.members[storyName] = []; + } + this.members[storyName]?.push(setup); + } + + unregister(setup: ClassSetupFunction, storyName: string) { + const setups = this.members[storyName]; + if (!setups) { + return; + } + + const index = setups.indexOf(setup); + if (index > -1) { + setups.splice(index, 1); + } + } + + setupFunctions(storyName: string): ClassSetupFunction[] | undefined { + return this.members[storyName]; + } + +} + +export const classSetupRegistry = new ClassSetupRegistry(); diff --git a/src/stories/hubbles_law/database.ts b/src/stories/hubbles_law/database.ts index 52712fa..0209d5f 100644 --- a/src/stories/hubbles_law/database.ts +++ b/src/stories/hubbles_law/database.ts @@ -911,3 +911,15 @@ export async function removeWaitingRoomOverride(classID: number): Promise NaN); } + +export async function hubbleClassSetup( + cls: Class, + _storyName: string, +) { + if (cls) { + console.log(cls); + if (cls.asynchronous || cls.small_class) { + await addClassToMergeGroup(cls.id); + } + } +} diff --git a/src/stories/hubbles_law/main.ts b/src/stories/hubbles_law/main.ts index d986f2b..f0c1701 100644 --- a/src/stories/hubbles_law/main.ts +++ b/src/stories/hubbles_law/main.ts @@ -1,5 +1,9 @@ +import { classSetupRegistry } from "../../registries"; +import { hubbleClassSetup } from "./database"; import { router, setup } from "./router"; +classSetupRegistry.register("hubbles_law", hubbleClassSetup); + module.exports = { path: "/hubbles_law", router,