model.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import { reactive, ref, readonly } from "vue";
  2. import { defineStore } from "pinia";
  3. import { generateUuid } from "@common/utils/generateUuid";
  4. import { forEachIn } from "@common/utils/forEachIn";
  5. import { useWebsocketStore } from "./websocket";
  6. import Model from "@/Model";
  7. import { ModelFetcher } from "@/ModelFetcher";
  8. /**
  9. * Pinia store for managing models
  10. */
  11. export const useModelStore = defineStore("model", () => {
  12. const { runJob, subscribe, subscribeMany, unsubscribe, unsubscribeMany } =
  13. useWebsocketStore();
  14. const models = reactive({});
  15. const permissions = reactive({});
  16. const createdSubcription = ref(null);
  17. const subscriptions = reactive({
  18. created: {},
  19. updated: {},
  20. deleted: {}
  21. });
  22. /**
  23. * Returns generic model permissions for the current user for a specific model type
  24. */
  25. const getUserModelPermissions = async (modelName: string) => {
  26. if (permissions[modelName]) return permissions[modelName];
  27. const data = await runJob("data.users.getModelPermissions", {
  28. modelName
  29. });
  30. permissions[modelName] = data;
  31. return permissions[modelName];
  32. };
  33. /**
  34. * Checks if we have a specific generic permission for a specific model type
  35. */
  36. const hasPermission = async (modelName: string, permission: string) => {
  37. const data = await getUserModelPermissions(modelName);
  38. return !!data[permission];
  39. };
  40. /**
  41. * This functions gets called when the backend notifies us that a model was created
  42. * We then notify every subscription in the frontend about it
  43. */
  44. const onCreatedCallback = async (modelName: string, data) => {
  45. if (!subscriptions.created[modelName]) return;
  46. await forEachIn(
  47. Object.values(subscriptions.created[modelName]),
  48. async subscription => subscription(data) // TODO: Error handling
  49. );
  50. };
  51. /**
  52. * This functions gets called when the backend notifies us that a model was updated
  53. * We then notify every subscription in the frontend about it
  54. */
  55. const onUpdatedCallback = async (modelName: string, { doc }) => {
  56. const model = models[modelName] && models[modelName][doc._id];
  57. if (model) model.updateData(doc);
  58. if (!subscriptions.updated[modelName]) return;
  59. await forEachIn(
  60. Object.values(subscriptions.updated[modelName]),
  61. async subscription => subscription(data) // TODO: Error handling
  62. );
  63. };
  64. /**
  65. * This functions gets called when the backend notifies us that a model was deleted
  66. * We then notify every subscription in the frontend about it
  67. */
  68. const onDeletedCallback = async (modelName: string, data) => {
  69. const { oldDoc } = data;
  70. if (subscriptions.deleted[modelName])
  71. await forEachIn(
  72. Object.values(subscriptions.deleted[modelName]),
  73. async subscription => subscription(data) // TODO: Error handling
  74. );
  75. const model = models[modelName] && models[modelName][oldDoc._id];
  76. // TODO how does this work with addUse?
  77. if (model) await unregisterModels(modelName, oldDoc._id); // eslint-disable-line no-use-before-define
  78. };
  79. /**
  80. * Subscribes the provided callback to model creation events
  81. * The provided callback will be called when the backend notifies us that a model of the provided type is created
  82. */
  83. const onCreated = async (
  84. modelName: string,
  85. callback: (data?: any) => any
  86. ) => {
  87. if (!createdSubcription.value)
  88. createdSubcription.value = await subscribe(
  89. `model.${modelName}.created`,
  90. data => onCreatedCallback(modelName, data)
  91. );
  92. const uuid = generateUuid();
  93. subscriptions.created[modelName] ??= {};
  94. subscriptions.created[modelName][uuid] = callback;
  95. return uuid;
  96. };
  97. /**
  98. * Subscribes the provided callback to model update events
  99. * The provided callback will be called when the backend notifies us that a model of the provided type is updated
  100. */
  101. const onUpdated = async (
  102. modelName: string,
  103. callback: (data?: any) => any
  104. ) => {
  105. const uuid = generateUuid();
  106. subscriptions.updated[modelName] ??= {};
  107. subscriptions.updated[modelName][uuid] = callback;
  108. return uuid;
  109. };
  110. /**
  111. * Subscribes the provided callback to model deletion events
  112. * The provided callback will be called when the backend notifies us that a model of the provided type is deleted
  113. */
  114. const onDeleted = async (
  115. modelName: string,
  116. callback: (data?: any) => any
  117. ) => {
  118. const uuid = generateUuid();
  119. subscriptions.deleted[modelName] ??= {};
  120. subscriptions.deleted[modelName][uuid] = callback;
  121. return uuid;
  122. };
  123. /**
  124. * Allows removing a specific subscription, so the callback of that subscription is no longer called when the backend notifies us
  125. * For type created, we also unsubscribe to events from the backend
  126. * For type updated/deleted, we are subscribed to specific model id updated/deleted events, those are not unsubscribed when
  127. * there's no subscriptions in the frontend actually using them
  128. */
  129. const removeCallback = async (
  130. modelName: string,
  131. type: "created" | "updated" | "deleted",
  132. uuid: string
  133. ) => {
  134. if (
  135. !subscriptions[type][modelName] ||
  136. !subscriptions[type][modelName][uuid]
  137. )
  138. return;
  139. delete subscriptions[type][modelName][uuid];
  140. if (
  141. type === "created" &&
  142. Object.keys(subscriptions.created[modelName]).length === 0
  143. ) {
  144. await unsubscribe(
  145. `model.${modelName}.created`,
  146. createdSubcription.value
  147. );
  148. createdSubcription.value = null;
  149. }
  150. };
  151. /**
  152. * Returns the model for the provided name and id
  153. * First tries to get the model from the already loaded models
  154. * If it's not already loaded, it fetches it from the backend
  155. *
  156. * Does not register the model that was fetched
  157. *
  158. * // TODO return value?
  159. */
  160. const findById = async (modelName: string, modelId: string) => {
  161. const existingModel = models[modelName] && models[modelName][modelId];
  162. if (existingModel) return existingModel;
  163. const [model] = (await ModelFetcher.fetchModelsByIds(modelName, [
  164. modelId
  165. ])) as unknown[];
  166. return model;
  167. };
  168. /**
  169. * Returns a list of models based on the provided model name and ids
  170. * First tries to get all models from the already loaded models
  171. * If after that we miss any models, we fetch those from the backend, and return everything we found
  172. *
  173. * Does not register the models that were fetched
  174. */
  175. const findManyById = async (modelName: string, modelIds: string[]) => {
  176. const existingModels = modelIds
  177. .map(modelId => models[modelName] && models[modelName][modelId])
  178. .filter(model => !!model);
  179. const existingModelIds = existingModels.map(model => model._id);
  180. const missingModelIds = modelIds.filter(
  181. _id => !existingModelIds.includes(_id)
  182. );
  183. let fetchedModels = [];
  184. if (missingModelIds.length > 0)
  185. fetchedModels = (await ModelFetcher.fetchModelsByIds(
  186. modelName,
  187. missingModelIds
  188. )) as unknown[];
  189. const allModels = existingModels.concat(fetchedModels);
  190. // Warning: returns models and direct results
  191. return Object.fromEntries(
  192. modelIds.map(modelId => [
  193. modelId,
  194. allModels.find(model => model._id === modelId)
  195. ])
  196. );
  197. };
  198. /**
  199. * Removes models locally if no one else is still using it
  200. * Also unsubscribes to any updated/deleted subscriptions
  201. */
  202. const unregisterModels = async (
  203. modelName: string,
  204. modelIdOrModelIds: string | string[]
  205. ) => {
  206. const modelIds = Array.isArray(modelIdOrModelIds)
  207. ? modelIdOrModelIds
  208. : [modelIdOrModelIds];
  209. const removeModels = [];
  210. await forEachIn(modelIds, async modelId => {
  211. if (!models[modelName] || !models[modelName][modelId]) return;
  212. const model = models[modelName][modelId];
  213. model.removeUse();
  214. if (model.getUses() > 1) return;
  215. // TODO only do this after a grace period
  216. removeModels.push(model);
  217. });
  218. if (removeModels.length === 0) return;
  219. await forEachIn(removeModels, async model =>
  220. model.unregisterRelations()
  221. );
  222. const subscriptions = Object.fromEntries(
  223. removeModels.flatMap(model => {
  224. const { updated, deleted } = model.getSubscriptions() ?? {};
  225. return [
  226. [
  227. updated,
  228. `model.${model.getName()}.updated.${model.getId()}`
  229. ],
  230. [
  231. deleted,
  232. `model.${model.getName()}.deleted.${model.getId()}`
  233. ]
  234. ];
  235. })
  236. );
  237. await unsubscribeMany(subscriptions);
  238. await forEachIn(removeModels, async removeModel => {
  239. const { _id: modelIdToRemove } = removeModel;
  240. if (!models[modelName] || !models[modelName][modelIdToRemove])
  241. return;
  242. delete models[modelName][modelIdToRemove];
  243. });
  244. };
  245. /**
  246. * Registers models/documents
  247. * If any models/documents already exist, increments the use counter, and tries to load any potentially missing relations
  248. * For documents that don't already exist, registers them locally, adds subscriptions for updated/deleted events,
  249. * increments the use counter, and loads any potential relations
  250. */
  251. const registerModels = async (
  252. documentsOrModels: any[],
  253. relations?: Record<string, string | string[]>
  254. ): Promise<Model[]> => {
  255. console.info("Register models", documentsOrModels, relations);
  256. const existingModels = documentsOrModels
  257. .map(({ _name, _id }) =>
  258. models[_name] ? models[_name][_id] ?? null : null
  259. )
  260. .filter(model => !!model);
  261. await forEachIn(existingModels, async model => {
  262. model.addUse();
  263. if (relations && relations[model._name])
  264. await model.loadRelations(relations[model._name]);
  265. });
  266. if (documentsOrModels.length === existingModels.length)
  267. return existingModels;
  268. const missingDocuments = documentsOrModels.filter(
  269. ({ _name, _id }) => !models[_name] || !models[_name][_id]
  270. );
  271. const channels = Object.fromEntries(
  272. missingDocuments.flatMap(({ _name, _id }) => [
  273. [
  274. `model.${_name}.updated.${_id}`,
  275. data => onUpdatedCallback(_name, data)
  276. ],
  277. [
  278. `model.${_name}.deleted.${_id}`,
  279. data => onDeletedCallback(_name, data)
  280. ]
  281. ])
  282. );
  283. const subscriptions = Object.entries(await subscribeMany(channels));
  284. const newModels = await forEachIn(missingDocuments, async document => {
  285. const { _name, _id } = document;
  286. const modelSubscriptions = subscriptions.filter(
  287. ([, { channel }]) =>
  288. channel.startsWith(`model.${_name}`) &&
  289. channel.endsWith(`.${_id}`)
  290. );
  291. const [updated] = modelSubscriptions.find(([, { channel }]) =>
  292. channel.includes(".updated.")
  293. );
  294. const [deleted] = modelSubscriptions.find(([, { channel }]) =>
  295. channel.includes(".deleted.")
  296. );
  297. if (!updated || !deleted) return null;
  298. const model = reactive(new Model(document));
  299. model.setSubscriptions(updated, deleted);
  300. model.addUse();
  301. // TODO what if relations are relevant for some registers, but not others? Unregister if no register relies on a relation
  302. if (relations && relations[_name])
  303. await model.loadRelations(relations[_name]);
  304. if (!models[_name]) {
  305. models[_name] = {};
  306. }
  307. models[_name][_id] = model;
  308. return model;
  309. });
  310. return existingModels.concat(newModels);
  311. };
  312. /**
  313. * Registers a model or document
  314. * Helper function to be able to register a single model/document, simply calls registerModels
  315. */
  316. const registerModel = async (
  317. documentOrModel: any,
  318. relations?: Record<string, string | string[]>
  319. ): Promise<Model> => {
  320. const [model] = await registerModels([documentOrModel], relations);
  321. return model;
  322. };
  323. /**
  324. * Loads one or more models for a provided model name and a provided model id or model ids, optionally including any relations
  325. * First fetches models from the already loaded models
  326. * Tries to fetch any missing models from the backend
  327. */
  328. const loadModels = async (
  329. modelName: string,
  330. modelIdsOrModelId: string | string[],
  331. relations?: Record<string, string | string[]>
  332. ): Promise<Map<string, Model | null>> => {
  333. const modelIds = Array.isArray(modelIdsOrModelId)
  334. ? modelIdsOrModelId
  335. : [modelIdsOrModelId];
  336. const existingModels = modelIds
  337. .map(_id => models[modelName] && models[modelName][_id])
  338. .filter(model => !!model);
  339. const existingModelIds = existingModels.map(model => model._id);
  340. const missingModelIds = modelIds.filter(
  341. _id => !existingModelIds.includes(_id)
  342. );
  343. const fetchedModels = await findManyById(modelName, missingModelIds);
  344. const registeredModels = await registerModels(
  345. Object.values(fetchedModels)
  346. .filter(model => !!model)
  347. .concat(existingModels),
  348. relations
  349. );
  350. const modelsNotFound = modelIds
  351. .filter(
  352. modelId =>
  353. !registeredModels.find(model => model.getId() === modelId)
  354. )
  355. .map(modelId => [modelId, null]);
  356. return Object.fromEntries(
  357. registeredModels
  358. .map(model => [model.getId(), model])
  359. .concat(modelsNotFound)
  360. );
  361. };
  362. return {
  363. models: readonly(models),
  364. permissions: readonly(permissions),
  365. subscriptions: readonly(subscriptions),
  366. onCreated,
  367. onUpdated,
  368. onDeleted,
  369. removeCallback,
  370. registerModel,
  371. registerModels,
  372. unregisterModels,
  373. loadModels,
  374. findById,
  375. findManyById,
  376. hasPermission
  377. };
  378. });