DataModule.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import config from "config";
  2. import { readdir } from "fs/promises";
  3. import path from "path";
  4. import { forEachIn } from "@common/utils/forEachIn";
  5. import {
  6. Sequelize,
  7. Model as SequelizeModel,
  8. ModelStatic,
  9. DataTypes,
  10. Utils,
  11. ModelOptions
  12. } from "sequelize";
  13. import { Dirent } from "fs";
  14. import * as inflection from "inflection";
  15. import BaseModule, { ModuleStatus } from "@/BaseModule";
  16. import DataModuleJob from "./DataModule/DataModuleJob";
  17. import Job from "@/Job";
  18. import EventsModule from "./EventsModule";
  19. export type ObjectIdType = string;
  20. // TODO fix TS
  21. // TODO implement actual checking of ObjectId's
  22. // TODO move to a better spot
  23. // Strange behavior would result if we extended DataTypes.ABSTRACT because
  24. // it's a class wrapped in a Proxy by Utils.classToInvokable.
  25. class OBJECTID extends DataTypes.ABSTRACT.prototype.constructor {
  26. // Mandatory: set the type key
  27. static key = "OBJECTID";
  28. key = OBJECTID.key;
  29. // Mandatory: complete definition of the new type in the database
  30. toSql() {
  31. return "VARCHAR(24)";
  32. }
  33. // Optional: validator function
  34. // @ts-ignore
  35. validate(value, options) {
  36. return true;
  37. // return (typeof value === 'number') && (!Number.isNaN(value));
  38. }
  39. // Optional: sanitizer
  40. // @ts-ignore
  41. _sanitize(value) {
  42. return value;
  43. // Force all numbers to be positive
  44. // return value < 0 ? 0 : Math.round(value);
  45. }
  46. // Optional: value stringifier before sending to database
  47. // @ts-ignore
  48. _stringify(value) {
  49. return value;
  50. // return value.toString();
  51. }
  52. // Optional: parser for values received from the database
  53. // @ts-ignore
  54. static parse(value) {
  55. return value;
  56. // return Number.parseInt(value);
  57. }
  58. }
  59. // Optional: add the new type to DataTypes. Optionally wrap it on `Utils.classToInvokable` to
  60. // be able to use this datatype directly without having to call `new` on it.
  61. DataTypes.OBJECTID = Utils.classToInvokable(OBJECTID);
  62. export class DataModule extends BaseModule {
  63. private _sequelize?: Sequelize;
  64. declare _jobs: Record<string, typeof Job | typeof DataModuleJob>;
  65. /**
  66. * Data Module
  67. */
  68. public constructor() {
  69. super("data");
  70. this._dependentModules = ["events"];
  71. }
  72. /**
  73. * startup - Startup data module
  74. */
  75. public override async startup() {
  76. await super.startup();
  77. await this._setupSequelize();
  78. // await this._runMigrations();
  79. await super._started();
  80. }
  81. /**
  82. * shutdown - Shutdown data module
  83. */
  84. public override async shutdown() {
  85. await super.shutdown();
  86. await this._sequelize?.close();
  87. await this._stopped();
  88. }
  89. /**
  90. * setupSequelize - Setup sequelize instance
  91. */
  92. private async _setupSequelize() {
  93. const { username, password, host, port, database } =
  94. config.get<any>("postgres");
  95. this._sequelize = new Sequelize(database, username, password, {
  96. host,
  97. port,
  98. dialect: "postgres",
  99. logging: message =>
  100. this.log({
  101. type: "debug",
  102. category: "sql",
  103. message
  104. }),
  105. define: {
  106. hooks: this._getSequelizeHooks()
  107. }
  108. });
  109. await this._sequelize.authenticate();
  110. const setupFunctions: Function[] = [];
  111. await forEachIn(
  112. await readdir(
  113. path.resolve(__dirname, `./${this.constructor.name}/models`),
  114. {
  115. withFileTypes: true
  116. }
  117. ),
  118. async modelFile => {
  119. if (!modelFile.isFile() || modelFile.name.includes(".spec."))
  120. return;
  121. const {
  122. default: ModelClass,
  123. schema,
  124. options = {},
  125. setup
  126. } = await import(`${modelFile.path}/${modelFile.name}`);
  127. const tableName = inflection.camelize(
  128. inflection.pluralize(ModelClass.name),
  129. true
  130. );
  131. ModelClass.init(schema, {
  132. tableName,
  133. ...options,
  134. sequelize: this._sequelize
  135. });
  136. if (typeof setup === "function") setupFunctions.push(setup);
  137. await this._loadModelEvents(ModelClass.name);
  138. await this._loadModelJobs(ModelClass.name);
  139. }
  140. );
  141. await forEachIn(setupFunctions, setup => setup());
  142. await this._sequelize.sync();
  143. // TODO move to a better spot and improve
  144. try {
  145. await this._sequelize.query(`DROP TABLE IF EXISTS "minifiedUsers"`);
  146. } catch (err) {}
  147. await this._sequelize.query(
  148. `CREATE OR REPLACE VIEW "minifiedUsers" AS SELECT _id, username, name, role FROM users`
  149. );
  150. }
  151. /**
  152. * getModel - Get model
  153. *
  154. * @returns Model
  155. */
  156. public async getModel<ModelType extends SequelizeModel<any>>(
  157. name: string
  158. ): Promise<ModelStatic<ModelType>> {
  159. if (!this._sequelize?.models) throw new Error("Models not loaded");
  160. if (this.getStatus() !== ModuleStatus.STARTED)
  161. throw new Error("Module not started");
  162. // TODO check if we want to do it via singularize&camelize, or another way
  163. const camelizedName = inflection.singularize(inflection.camelize(name));
  164. return this._sequelize.model(camelizedName) as ModelStatic<ModelType>; // This fails - news has not been defined
  165. }
  166. private _getSequelizeHooks(): ModelOptions<SequelizeModel>["hooks"] {
  167. return {
  168. afterSave: console.log,
  169. afterCreate: async model => {
  170. const modelName = (
  171. model.constructor as ModelStatic<any>
  172. ).getTableName();
  173. let EventClass;
  174. try {
  175. EventClass = this.getEvent(`${modelName}.created`);
  176. } catch (error) {
  177. // TODO: Catch and ignore only event not found
  178. return;
  179. }
  180. EventsModule.publish(
  181. new EventClass({
  182. doc: model.get()
  183. })
  184. );
  185. },
  186. afterUpdate: async model => {
  187. const modelName = (
  188. model.constructor as ModelStatic<any>
  189. ).getTableName();
  190. let EventClass;
  191. try {
  192. EventClass = this.getEvent(`${modelName}.updated`);
  193. } catch (error) {
  194. // TODO: Catch and ignore only event not found
  195. return;
  196. }
  197. EventsModule.publish(
  198. new EventClass(
  199. {
  200. doc: model.get(),
  201. oldDoc: model.previous()
  202. },
  203. model.get("_id") ?? model.previous("_id")
  204. )
  205. );
  206. },
  207. afterDestroy: async model => {
  208. const modelName = (
  209. model.constructor as ModelStatic<any>
  210. ).getTableName();
  211. let EventClass;
  212. try {
  213. EventClass = this.getEvent(`${modelName}.deleted`);
  214. } catch (error) {
  215. // TODO: Catch and ignore only event not found
  216. return;
  217. }
  218. EventsModule.publish(
  219. new EventClass(
  220. {
  221. oldDoc: model.previous()
  222. },
  223. model.previous("_id")
  224. )
  225. );
  226. }
  227. };
  228. }
  229. private async _loadModelJobs(modelClassName: string) {
  230. let jobs: Dirent[];
  231. try {
  232. jobs = await readdir(
  233. path.resolve(
  234. __dirname,
  235. `./${this.constructor.name}/models/${modelClassName}/jobs/`
  236. ),
  237. {
  238. withFileTypes: true
  239. }
  240. );
  241. } catch (error) {
  242. if (
  243. error instanceof Error &&
  244. "code" in error &&
  245. error.code === "ENOENT"
  246. ) {
  247. this.log(
  248. `Loading ${modelClassName} jobs failed - folder doesn't exist`
  249. );
  250. return;
  251. }
  252. throw error;
  253. }
  254. await forEachIn(jobs, async jobFile => {
  255. if (!jobFile.isFile() || jobFile.name.includes(".spec.")) return;
  256. const { default: JobClass } = await import(
  257. `${jobFile.path}/${jobFile.name}`
  258. );
  259. this._jobs[JobClass.getName()] = JobClass;
  260. });
  261. }
  262. private async _loadModelEvents(modelClassName: string) {
  263. let events: Dirent[];
  264. try {
  265. events = await readdir(
  266. path.resolve(
  267. __dirname,
  268. `./${this.constructor.name}/models/${modelClassName}/events/`
  269. ),
  270. {
  271. withFileTypes: true
  272. }
  273. );
  274. } catch (error) {
  275. if (
  276. error instanceof Error &&
  277. "code" in error &&
  278. error.code === "ENOENT"
  279. )
  280. return;
  281. throw error;
  282. }
  283. await forEachIn(events, async eventFile => {
  284. if (!eventFile.isFile() || eventFile.name.includes(".spec."))
  285. return;
  286. const { default: EventClass } = await import(
  287. `${eventFile.path}/${eventFile.name}`
  288. );
  289. this._events[EventClass.getName()] = EventClass;
  290. });
  291. }
  292. }
  293. export default new DataModule();