models/models.js

  1. import {
  2. NoStoreFound,
  3. StoreNotReady,
  4. AssignmentMustBeArray,
  5. InvalidAssignment,
  6. RequiredFieldsMissing,
  7. } from "../errors.js";
  8. import { copyFromSource, setDataFrom } from "./utils.js";
  9. import { registry } from "./model-registry.js";
  10. import { Model } from "./model.js";
  11. import * as basicSchema from "../schema/basic-js-schema.js";
  12. import { buildValidatingArray } from "./build-validating-array.js";
  13. import { FileSystemStore } from "./store/filesystem-store.js";
  14. import * as fields from "./fields.js";
  15. const { Fields } = fields;
  16. /**
  17. * This is, effectively, the Model manager and factory.
  18. * @hideconstructor
  19. */
  20. export class Models {
  21. /**
  22. * Used by save/load functions.
  23. * @param {*} store
  24. * @returns the static Models class, for call chaining purposes.
  25. */
  26. static setStore(store) {
  27. this.store = store;
  28. registry.setStore(store);
  29. return this;
  30. }
  31. /**
  32. * Async dynamic import, so that we don't end up bundling `fs` related
  33. * file storage during a client-bundling operation.
  34. * @param {*} path
  35. */
  36. static async useDefaultStore(path) {
  37. await FileSystemStore.bootstrap();
  38. const store = new FileSystemStore(path);
  39. Models.setStore(store);
  40. }
  41. /**
  42. * Make sure that the store is ready for load/save operations.
  43. * @ignore
  44. */
  45. static verifyStore() {
  46. if (!this.store) {
  47. throw new NoStoreFound();
  48. }
  49. if (!this.store.ready()) {
  50. throw new StoreNotReady();
  51. }
  52. }
  53. /**
  54. * <p>register all model classes so that we know whether or not
  55. * they still match their previously stored schema. If not,
  56. * this will throw and you should run a schema migration before
  57. * your model-related code will run without errors.</p>
  58. *
  59. * <p>If a backend store is used, this function will run <code>async</code>,
  60. * returning a <code>Promise</code> that can be <code>await</code>ed,
  61. * or handled with <code>.then()</code></p>
  62. *
  63. * <p>When no backend is used, this function will run synchronously.</p>
  64. *
  65. * @param {Model[]} models - one or more Model class instances
  66. * @returns {schema[]} A list of model-associated schemas, mapped per input model
  67. */
  68. static register(...models) {
  69. if (this.store) return this.__registerAsync(...models);
  70. return this.__registerSync(...models);
  71. }
  72. /** @ignore */
  73. static async __registerAsync(...models) {
  74. const list = models.slice();
  75. while (list.length) {
  76. await registry.recordModelClassAsync(list.shift());
  77. }
  78. return models.map((model) => registry.getRegisteredSchema(model.name));
  79. }
  80. /** @ignore */
  81. static __registerSync(...models) {
  82. return models.map((model) => registry.recordModelClassSync(model));
  83. }
  84. /**
  85. * Forget all registered models
  86. */
  87. static resetRegistrations() {
  88. registry.resetRegistrations();
  89. }
  90. /**
  91. * <p>Create a model instance.</p>
  92. *
  93. * @param {class} Model - The model class to instantiate.
  94. * @param {object} data - the data with which to bootstrap the new model instantiation.
  95. * @param {boolean} [allowIncomplete] - True if missing required fields should be allowed, false if not.
  96. * @returns {Model} an instance of the passed Model class.
  97. */
  98. static create(Model, data, allowIncomplete = false) {
  99. if (!this.store) this.register(Model);
  100. const { name, schema } = Model;
  101. // if we're using a data store, and don't know this model, this will throw.
  102. if (this.store) registry.getRegisteredSchema(name);
  103. const instance = new Model(this, Date.now());
  104. fromSchemaToData(instance);
  105. // Assign this model's initial data. This will throw if any values do not
  106. // conform to the model's schema.
  107. if (data !== undefined) setDataFrom(data, instance);
  108. // Then, post-validate the instance.
  109. const result = basicSchema.validate(
  110. schema,
  111. instance,
  112. false,
  113. allowIncomplete
  114. );
  115. if (!result.passed) {
  116. throw new RequiredFieldsMissing(name, result.errors);
  117. }
  118. if (allowIncomplete === Model.ALLOW_INCOMPLETE) {
  119. Object.defineProperty(instance, `__incomplete`, {
  120. enumerable: false,
  121. configurable: true,
  122. value: true,
  123. });
  124. }
  125. return instance;
  126. }
  127. /**
  128. * Load a model from file (i.e. create a model, then assign values to it based on
  129. * stored data. We do it in this order to ensure data validation runs)
  130. * @param {class} Model - The model class to instantiate.
  131. * @param {String} recordName - The recordName associated with the required instance.
  132. * @returns {Model} a previously stored instance of the passed Model class.
  133. */
  134. static async loadModel(Model, recordName) {
  135. this.verifyStore();
  136. const schema = await registry.recordModelClassAsync(Model);
  137. // Preallocate our data variable, and see if we can assign and use it.
  138. // Which can fail. In quite a few ways. All of them will throw =)
  139. let fileData = undefined;
  140. if (recordName) {
  141. fileData = await this.store.loadRecord(schema, recordName);
  142. }
  143. try {
  144. return this.create(Model, fileData);
  145. } catch (e) {
  146. // And this is where things get interesting: schema mismatch, what do we do?
  147. console.error(
  148. `Data for stored record ${recordName} is not schema-conformant.`
  149. );
  150. throw e;
  151. }
  152. }
  153. /**
  154. * Save a model to the back end, but skip any default values
  155. * because models are bootstrapped with the model's default
  156. * values before data gets loaded in.
  157. * @param {Model} instance - A model instance.
  158. */
  159. static async saveModel(instance) {
  160. this.verifyStore();
  161. const modelName = instance.__proto__.constructor.name;
  162. const schema = registry.getRegisteredSchema(modelName);
  163. const recordName = basicSchema.getRecordNameFor(schema, instance);
  164. await this.store.saveRecord(schema, instance, recordName);
  165. }
  166. /**
  167. * Delete a model from the back end.
  168. * @param {Model} instance - A model instance.
  169. */
  170. static async deleteModel(instance) {
  171. this.verifyStore();
  172. const modelName = instance.__proto__.constructor.name;
  173. const schema = registry.getRegisteredSchema(modelName);
  174. const recordName = basicSchema.getRecordNameFor(schema, instance);
  175. await this.store.deleteRecord(schema, recordName);
  176. }
  177. // And some convenience "static exports"
  178. static fields = Fields;
  179. }
  180. /**
  181. * Rewrite a model from its initial "schema" layout
  182. * to the actually usable "controlled data" layout.
  183. * @ignore
  184. */
  185. export function fromSchemaToData(model) {
  186. if (model.__converted) return model;
  187. const props = Object.entries(model);
  188. props.forEach(([key, definition]) => {
  189. const array = key !== `__meta` && definition.__meta.array;
  190. const { shape } = definition;
  191. if (shape) {
  192. definition = shape;
  193. }
  194. // we don't need to retain metadata, this is instead
  195. // kept around in the Models.modelTrees dictionary.
  196. if (key === `__meta`) {
  197. delete model.__meta;
  198. }
  199. // non-model subtrees
  200. else if (!!shape || definition.__meta?.name) {
  201. let schema;
  202. // If this is a proper model, we should already have its associated
  203. // schema stored both in the registry and on the model class (set
  204. // as part of the registry.recordModelClass() code path)
  205. if (typeof Model !== `undefined` && definition instanceof Model) {
  206. schema = definition.__proto__.constructor.schema;
  207. }
  208. // If not, treat the definition as the schema.
  209. else {
  210. schema = copyFromSource(definition);
  211. }
  212. // If this is an array-of-[...], we need a special array that
  213. // can perform seemless data assignment/extraction.
  214. if (array) {
  215. const proxy = buildValidatingArray(schema, definition);
  216. Object.defineProperty(model, key, {
  217. configurable: false,
  218. get: () => proxy,
  219. set: (data) => {
  220. if (!(data instanceof Array)) {
  221. throw new AssignmentMustBeArray(key);
  222. }
  223. while (proxy.length > 0) proxy.pop();
  224. proxy.push(...data);
  225. },
  226. });
  227. }
  228. // Otherwise, we can set up "simple" get/set logic.
  229. else {
  230. Object.defineProperty(model, key, {
  231. configurable: false,
  232. get: () => definition,
  233. set: (data) => {
  234. const result = basicSchema.validate(schema, data);
  235. if (result.passed) setDataFrom(data, definition);
  236. else {
  237. throw new InvalidAssignment(key, data, result.errors);
  238. }
  239. },
  240. });
  241. }
  242. // And then we recurse.
  243. fromSchemaToData(definition);
  244. }
  245. // everything else is a simple (validation-controlled) property
  246. else setupReferenceHandler(model, key, definition);
  247. });
  248. Object.defineProperty(model, `__converted`, {
  249. configurable: false,
  250. enumerable: false,
  251. writable: false,
  252. value: true,
  253. });
  254. return model;
  255. }
  256. /**
  257. * Set up the property to initially be undefined and non-enumerable.
  258. * When the property is assigned a value that is not the default,
  259. * we toggle the field to enumerable so that it "shows up" when
  260. * using Object.keys/values/entries and JSON serialization.
  261. *
  262. * @ignore
  263. */
  264. export function setupReferenceHandler(model, key, definition) {
  265. const defaultValue = definition.default;
  266. let __proxy = defaultValue;
  267. Object.defineProperty(model, key, {
  268. configurable: true, // defaults to false, so needs to explicitly be set to true
  269. enumerable: false, // hide this key for object iteration purposes by default
  270. get: () => __proxy,
  271. set: (value) => {
  272. const result = fields.validate(key, value, definition);
  273. if (result.passed) {
  274. __proxy = value;
  275. // For non default values, include this key when iterating over the object,
  276. // but default values exclude this key for iteration purposes.
  277. Object.defineProperty(model, key, {
  278. enumerable: value !== defaultValue,
  279. });
  280. } else {
  281. throw new InvalidAssignment(key, value, result.errors);
  282. }
  283. },
  284. });
  285. }