@acodeninja/persist 3.0.0-next.26 → 3.0.0-next.27

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.
@@ -173,3 +173,34 @@ class RequiredStringModel extends Persist.Model {
173
173
  }
174
174
  }
175
175
  ```
176
+
177
+ ## Custom Property Types
178
+
179
+ Under the hood, model validation uses the [ajv](https://ajv.js.org/) library with [ajv-formats](https://ajv.js.org/packages/ajv-formats.html) included. Because of this, you can create your own property types.
180
+
181
+ Say you want to attach an IPv4 address to your models. The following type class can accomplish this.
182
+
183
+ ```javascript
184
+ import Persist from '@acodeninja/persist';
185
+
186
+ class IPv4Type extends Persist.Property.Type {
187
+ static {
188
+ // Set the type of the property to string
189
+ this._type = 'string';
190
+ // Use the ajv extended format "ipv4"
191
+ this._format = 'ipv4';
192
+ // Ensure that even when minified, the name of the constructor is IPv4
193
+ Object.defineProperty(this, 'name', {value: 'IPv4'});
194
+ }
195
+ }
196
+ ```
197
+
198
+ This type can then be used in any model as needed.
199
+
200
+ ```javascript
201
+ import Persist from '@acodeninja/persist';
202
+
203
+ class StaticIP extends Persist.Model {
204
+ static ip = IPv4Type.required;
205
+ }
206
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acodeninja/persist",
3
- "version": "3.0.0-next.26",
3
+ "version": "3.0.0-next.27",
4
4
  "description": "A JSON based data modelling and persistence module with alternate storage mechanisms.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/Connection.js CHANGED
@@ -30,7 +30,7 @@ export default class Connection {
30
30
  */
31
31
  constructor(storage, models) {
32
32
  this.#storage = storage;
33
- this.#models = Object.fromEntries((models ?? []).map(model => [model.name, model]));
33
+ this.#models = resolveModels(Object.fromEntries((models ?? []).map(model => [model.name, model])));
34
34
 
35
35
  if (!this.#storage) throw new MissingArgumentsConnectionError('No storage engine provided');
36
36
  }
@@ -58,11 +58,10 @@ export default class Connection {
58
58
  *
59
59
  * and fetches all data for a model.
60
60
  * @param {Object} dryModel
61
+ * @param {Map?} modelCache
61
62
  * @return {Promise<Model>}
62
63
  */
63
- async hydrate(dryModel) {
64
- const hydratedModels = {};
65
-
64
+ async hydrate(dryModel, modelCache = new Map()) {
66
65
  /**
67
66
  * Recursively hydrates a single model and its nested properties.
68
67
  *
@@ -70,7 +69,7 @@ export default class Connection {
70
69
  * @returns {Promise<Model>} The hydrated model instance.
71
70
  */
72
71
  const hydrateModel = async (modelToProcess) => {
73
- hydratedModels[modelToProcess.id] = modelToProcess;
72
+ modelCache.set(modelToProcess.id, modelToProcess);
74
73
 
75
74
  for (const [name, property] of Object.entries(modelToProcess)) {
76
75
  if (Model.isDryModel(property)) {
@@ -82,6 +81,8 @@ export default class Connection {
82
81
  }
83
82
  }
84
83
 
84
+ modelCache.set(modelToProcess.id, modelToProcess);
85
+
85
86
  return modelToProcess;
86
87
  };
87
88
 
@@ -92,14 +93,12 @@ export default class Connection {
92
93
  * @returns {Promise<Model>} The fully hydrated sub-model.
93
94
  */
94
95
  const hydrateSubModel = async (property) => {
95
- if (hydratedModels[property.id]) {
96
- return hydratedModels[property.id];
97
- }
96
+ if (modelCache.has(property.id)) return modelCache.get(property.id);
98
97
 
99
98
  const subModel = await this.get(property.id);
100
99
 
101
100
  const hydratedSubModel = await hydrateModel(subModel);
102
- hydratedModels[property.id] = hydratedSubModel;
101
+ modelCache.set(property.id, hydratedSubModel);
103
102
  return hydratedSubModel;
104
103
  };
105
104
 
@@ -111,20 +110,18 @@ export default class Connection {
111
110
  */
112
111
  const hydrateModelList = async (property) => {
113
112
  const newModelList = await Promise.all(property.map(subModel => {
114
- if (hydratedModels[subModel.id]) {
115
- return hydratedModels[subModel.id];
116
- }
113
+ if (modelCache.has(subModel.id)) return modelCache.get(subModel.id);
117
114
 
118
115
  return this.get(subModel.id);
119
116
  }));
120
117
 
121
118
  return Promise.all(newModelList.map(async subModel => {
122
- if (hydratedModels[subModel.id]) {
123
- return hydratedModels[subModel.id];
124
- }
119
+ if (modelCache.has(subModel.id)) return modelCache.get(subModel.id);
125
120
 
126
121
  const hydratedSubModel = await hydrateModel(subModel);
127
- hydratedModels[hydratedSubModel.id] = hydratedSubModel;
122
+
123
+ modelCache.set(hydratedSubModel.id, hydratedSubModel);
124
+
128
125
  return hydratedSubModel;
129
126
  }));
130
127
  };
@@ -153,7 +150,7 @@ export default class Connection {
153
150
 
154
151
  processedModels.push(modelToProcess.id);
155
152
 
156
- if (!Object.keys(this.#models).includes(modelToProcess.constructor.name))
153
+ if (!this.#models.has(modelToProcess.constructor.name))
157
154
  throw new ModelNotRegisteredConnectionError(modelToProcess, this.#storage);
158
155
 
159
156
  modelToProcess.validate();
@@ -197,7 +194,7 @@ export default class Connection {
197
194
  await Promise.all([
198
195
  Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
199
196
  Promise.all(Object.entries(modelsToReindex).map(async ([constructorName, models]) => {
200
- const modelConstructor = this.#models[constructorName];
197
+ const modelConstructor = this.#models.get(constructorName);
201
198
  const index = await this.#storage.getIndex(modelConstructor);
202
199
 
203
200
  await this.#storage.putIndex(modelConstructor, {
@@ -206,7 +203,7 @@ export default class Connection {
206
203
  });
207
204
  })),
208
205
  Promise.all(Object.entries(modelsToReindexSearch).map(async ([constructorName, models]) => {
209
- const modelConstructor = this.#models[constructorName];
206
+ const modelConstructor = this.#models.get(constructorName);
210
207
  const index = await this.#storage.getSearchIndex(modelConstructor);
211
208
 
212
209
  await this.#storage.putSearchIndex(modelConstructor, {
@@ -219,158 +216,148 @@ export default class Connection {
219
216
 
220
217
  /**
221
218
  * Delete a model and update indexes that reference it
222
- * @param {Model} model
223
- * @param {Array<string>} propagateTo - List of model ids that are expected to be deleted
219
+ * @param {Model} subject
220
+ * @param {Array<string>} propagateTo - List of model ids that are expected to be deleted or updated.
224
221
  * @throws {ModelNotRegisteredConnectionError}
225
222
  * @throws {ModelNotFoundStorageEngineError}
226
223
  */
227
- async delete(model, propagateTo = []) {
228
- const processedModels = [];
229
- const modelsToDelete = [];
230
- const modelsToPut = [];
231
- const indexCache = {};
232
- const indexActions = {};
233
- const searchIndexCache = {};
234
- const searchIndexActions = {};
235
- const modelCache = {};
224
+ async delete(subject, propagateTo = []) {
225
+ const modelCache = new Map();
226
+ const modelsToCheck = this.#findLinkedModelClasses(subject);
227
+ const modelsToDelete = new Set([subject.id]);
228
+ const modelsToUpdate = new Set();
229
+ const indexesToUpdate = new Set();
230
+ const searchIndexesToUpdate = new Set();
231
+
232
+ subject = await this.hydrate(subject, modelCache);
233
+
234
+ if (!propagateTo.includes(subject.id)) {
235
+ propagateTo.push(subject.id);
236
+ }
236
237
 
237
- propagateTo.push(model.id);
238
+ // Populate index and search index cache.
239
+ const [indexCache, searchIndexCache] = await this.#getModelIndexes(modelsToCheck);
238
240
 
239
- /**
240
- * Process a model for deletion
241
- * @param {Model} modelToProcess
242
- * @return {Promise<void>}
243
- */
244
- const processModel = async (modelToProcess) => {
245
- if (processedModels.includes(modelToProcess.id)) return;
241
+ // Add model to be removed to index caches.
242
+ if (!indexCache.has(subject.constructor.name)) {
243
+ indexCache.set(subject.constructor.name, (await this.#storage.getIndex(subject.constructor)));
244
+ }
246
245
 
247
- processedModels.push(modelToProcess.id);
246
+ if (!searchIndexCache.has(subject.constructor.name)) {
247
+ searchIndexCache.set(subject.constructor.name, (await this.#storage.getSearchIndex(subject.constructor)));
248
+ }
248
249
 
249
- const modelsToProcess = [];
250
- if (!Object.keys(this.#models).includes(modelToProcess.constructor.name))
251
- throw new ModelNotRegisteredConnectionError(modelToProcess, this.#storage);
250
+ // Populate model cache
251
+ for (const [[modelName, propertyName, type, direction], _modelConstructor] of modelsToCheck) {
252
+ const query = {};
252
253
 
253
- const currentModel = modelCache[modelToProcess.id] ?? await this.get(modelToProcess.id);
254
- modelCache[currentModel.id] = currentModel;
254
+ if (direction === 'up') {
255
+ if (type === 'one') {
256
+ query[propertyName] = {id: {$is: subject.id}};
257
+ }
258
+
259
+ if (type === 'many') {
260
+ query[propertyName] = {
261
+ $contains: {
262
+ id: {$is: subject.id},
263
+ },
264
+ };
265
+ }
266
+ }
255
267
 
256
- if (!modelsToDelete.includes(currentModel.id)) modelsToDelete.push(currentModel.id);
268
+ const foundModels =
269
+ _.isEqual(query, {}) ?
270
+ (
271
+ Array.isArray(subject[propertyName]) ?
272
+ subject[propertyName] : [subject[propertyName]]
273
+ ) : new FindIndex(this.#models.get(modelName), indexCache.get(modelName)).query(query);
257
274
 
258
- const modelToProcessConstructor = this.#getModelConstructorFromId(modelToProcess.id);
275
+ for (const foundModel of foundModels) {
276
+ if (!modelCache.has(foundModel.id)) {
277
+ modelCache.set(foundModel.id, await this.hydrate(foundModel, modelCache));
278
+ }
279
+ }
280
+
281
+ // for deletes, update models that link to the subject
282
+ if (direction === 'up') {
283
+ if (type === 'one') {
284
+ for (const foundModel of foundModels) {
285
+ const cachedModel = modelCache.get(foundModel.id);
259
286
 
260
- indexActions[modelToProcessConstructor.name] = indexActions[modelToProcessConstructor.name] ?? [];
261
- searchIndexActions[modelToProcessConstructor.name] = searchIndexActions[modelToProcessConstructor.name] ?? [];
287
+ if (foundModel.constructor[propertyName]._required) {
288
+ modelsToDelete.add(foundModel.id);
289
+ continue;
290
+ }
262
291
 
263
- indexActions[modelToProcessConstructor.name].push(['delete', modelToProcess]);
292
+ cachedModel[propertyName] = undefined;
293
+ modelCache.set(foundModel.id, cachedModel);
294
+ modelsToUpdate.add(foundModel.id);
295
+ }
296
+ }
264
297
 
265
- if (currentModel.constructor.searchProperties().length) {
266
- searchIndexActions[modelToProcessConstructor.name].push(['delete', modelToProcess]);
298
+ if (type === 'many') {
299
+ for (const foundModel of foundModels) {
300
+ const cachedModel = modelCache.get(foundModel.id);
301
+
302
+ cachedModel[propertyName] = cachedModel[propertyName].filter(m => m.id !== subject.id);
303
+ modelCache.set(foundModel.id, cachedModel);
304
+ modelsToUpdate.add(foundModel.id);
305
+ }
306
+ }
267
307
  }
308
+ }
309
+
310
+ const unrequestedDeletions = [...modelsToDelete].filter(id => !propagateTo.includes(id));
311
+ const unrequestedUpdates = [...modelsToUpdate].filter(id => !propagateTo.includes(id));
312
+
313
+ if (unrequestedDeletions.length || unrequestedUpdates.length) {
314
+ throw new DeleteHasUnintendedConsequencesStorageEngineError(subject.id, {
315
+ willDelete: unrequestedDeletions.map(id => modelCache.get(id)),
316
+ willUpdate: unrequestedUpdates.map(id => modelCache.get(id)),
317
+ });
318
+ }
268
319
 
269
- const linkedModels = await this.#getInstancesLinkedTo(modelToProcess, indexCache);
270
- const links = this.#getLinksFor(modelToProcess.constructor);
271
- Object.values(Object.fromEntries(await Promise.all(
272
- Object.entries(linkedModels)
273
- .map(async ([modelConstructor, updatableModels]) => [
274
- modelConstructor,
275
- await Promise.all(updatableModels.map(async m => {
276
- const upToDateModel = modelCache[m.id] ?? await this.get(m.id);
277
- modelCache[upToDateModel.id] = upToDateModel;
278
- return upToDateModel;
279
- })),
280
- ]),
281
- ))).flat(1)
282
- .forEach(m =>
283
- Object.entries(links[m.constructor.name])
284
- .forEach(([linkName, modelConstructor]) => {
285
- if ((
286
- typeof modelConstructor[linkName] === 'function' &&
287
- !/^class/.test(Function.prototype.toString.call(modelConstructor[linkName])) &&
288
- !Model.isModel(modelConstructor[linkName]) ?
289
- modelConstructor[linkName]() : modelConstructor
290
- )._required) {
291
- if (!modelsToDelete.includes(m.id)) modelsToDelete.push(m.id);
292
- modelsToProcess.push(m);
293
- } else {
294
- const foundModelConstructor = this.#getModelConstructorFromId(m.id);
295
- m[linkName] = undefined;
296
- modelsToPut.push(m);
297
-
298
- indexActions[foundModelConstructor.name] = indexActions[foundModelConstructor.name] ?? [];
299
- indexActions[foundModelConstructor.name].push(['reindex', m]);
300
-
301
- if (m.constructor.searchProperties().length) {
302
- searchIndexActions[foundModelConstructor.name] = searchIndexActions[foundModelConstructor.name] ?? [];
303
- searchIndexActions[foundModelConstructor.name].push(['reindex', m]);
304
- }
305
- }
306
- }),
307
- );
308
-
309
- for (const modelToBeProcessed of modelsToProcess) {
310
- await processModel(modelToBeProcessed);
320
+ for (const modelId of [...modelsToDelete, ...modelsToUpdate]) {
321
+ const modelConstructorName = modelId.split('/')[0];
322
+ indexesToUpdate.add(modelConstructorName);
323
+ if (this.#models.get(modelConstructorName)?.searchProperties().length) {
324
+ searchIndexesToUpdate.add(modelConstructorName);
311
325
  }
312
- };
326
+ }
313
327
 
314
- await processModel(model);
328
+ for (const indexName of searchIndexesToUpdate) {
329
+ const index = searchIndexCache.get(indexName);
315
330
 
316
- const unrequestedDeletions = modelsToDelete.filter(m => !propagateTo.includes(m));
331
+ for (const model of [...modelsToUpdate].filter(i => i.startsWith(indexName))) {
332
+ index[model] = modelCache.get(model).toSearchData();
333
+ }
317
334
 
318
- if (unrequestedDeletions.length) {
319
- throw new DeleteHasUnintendedConsequencesStorageEngineError(model.id, {
320
- willDelete: unrequestedDeletions,
321
- });
335
+ for (const model of [...modelsToDelete].filter(i => i.startsWith(indexName))) {
336
+ delete index[model];
337
+ }
338
+
339
+ searchIndexCache.set(indexName, index);
322
340
  }
323
341
 
324
- await Promise.all([
325
- Promise.all(Object.entries(indexActions).map(async ([constructorName, actions]) => {
326
- const modelConstructor = this.#models[constructorName];
327
- indexCache[constructorName] = indexCache[constructorName] ?? await this.#storage.getIndex(modelConstructor);
342
+ for (const indexName of indexesToUpdate) {
343
+ const index = indexCache.get(indexName);
328
344
 
329
- actions.forEach(([action, actionModel]) => {
330
- if (action === 'delete') {
331
- indexCache[constructorName] = _.omit(indexCache[constructorName], [actionModel.id]);
332
- }
333
- if (action === 'reindex') {
334
- indexCache[constructorName] = {
335
- ...indexCache[constructorName],
336
- [actionModel.id]: actionModel.toIndexData(),
337
- };
338
- }
339
- });
340
- })),
341
- Promise.all(Object.entries(searchIndexActions).map(async ([constructorName, actions]) => {
342
- const modelConstructor = this.#models[constructorName];
343
- searchIndexCache[constructorName] = searchIndexCache[constructorName] ?? await this.#storage.getSearchIndex(modelConstructor);
345
+ for (const model of [...modelsToUpdate].filter(i => i.startsWith(indexName))) {
346
+ index[model] = modelCache.get(model).toIndexData();
347
+ }
344
348
 
345
- actions.forEach(([action, actionModel]) => {
346
- if (action === 'delete') {
347
- searchIndexCache[constructorName] = _.omit(searchIndexCache[constructorName], [actionModel.id]);
348
- }
349
- if (action === 'reindex') {
350
- searchIndexCache[constructorName] = {
351
- ...searchIndexCache[constructorName],
352
- [actionModel.id]: actionModel.toSearchData(),
353
- };
354
- }
355
- });
356
- })),
357
- ]);
349
+ for (const model of [...modelsToDelete].filter(i => i.startsWith(indexName))) {
350
+ delete index[model];
351
+ }
352
+
353
+ indexCache.set(indexName, index);
354
+ }
358
355
 
359
356
  await Promise.all([
360
- Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
361
- Promise.all(modelsToDelete.map(m => this.#storage.deleteModel(m))),
362
- Promise.all(
363
- Object.entries(indexCache)
364
- .map(([constructorName, index]) => this.#storage.putIndex(this.#models[constructorName], index)),
365
- ),
366
- Promise.all(
367
- Object.entries(searchIndexCache)
368
- .map(([constructorName, index]) =>
369
- this.#models[constructorName].searchProperties().length > 0 ?
370
- this.#storage.putSearchIndex(this.#models[constructorName], index) :
371
- Promise.resolve(),
372
- ),
373
- ),
357
+ Promise.all([...modelsToUpdate].map(id => this.#storage.putModel(modelCache.get(id).toData()))),
358
+ Promise.all([...modelsToDelete].map(id => this.#storage.deleteModel(id))),
359
+ Promise.all([...indexesToUpdate].map(index => this.#storage.putIndex(this.#models.get(index), indexCache.get(index)))),
360
+ Promise.all([...searchIndexesToUpdate].map(index => this.#storage.putSearchIndex(this.#models.get(index), searchIndexCache.get(index)))),
374
361
  ]);
375
362
  }
376
363
 
@@ -418,7 +405,7 @@ export default class Connection {
418
405
 
419
406
  const engine = CreateTransactionalStorageEngine(operations, this.#storage);
420
407
 
421
- const transaction = new this.constructor(engine, Object.values(this.#models));
408
+ const transaction = new this.constructor(engine, this.#models.values());
422
409
 
423
410
  transaction.commit = async () => {
424
411
  try {
@@ -473,7 +460,7 @@ export default class Connection {
473
460
  */
474
461
  #getModelConstructorFromId(modelId) {
475
462
  const modelName = modelId.split('/')[0];
476
- const modelConstructor = this.#models[modelName];
463
+ const modelConstructor = this.#models.get(modelName);
477
464
 
478
465
  if (!modelConstructor) throw new ModelNotRegisteredConnectionError(modelName, this.#storage);
479
466
 
@@ -481,80 +468,147 @@ export default class Connection {
481
468
  }
482
469
 
483
470
  /**
484
- * Get model classes that are directly linked to the given model in either direction
485
- * @param {Model.constructor} model
486
- * @return {Record<string, Record<string, Model.constructor>>}
471
+ * Retrieves and caches index and search index information for specified models.
472
+ *
473
+ * @private
474
+ * @async
475
+ * @param {Array<Array<string, *>>} modelsToCheck - An array of arrays where the first element
476
+ * of each inner array is the model name to retrieve indexes for
477
+ * @returns {Promise<[Map<string, *>, Map<string, *>]>} A promise that resolves to a tuple containing:
478
+ * - indexCache: A Map of model names to their corresponding indexes
479
+ * - searchIndexCache: A Map of model names to their corresponding search indexes
480
+ *
481
+ * @description
482
+ * This method populates two caches for the specified models:
483
+ * 1. A regular index cache retrieved via storage.getIndex()
484
+ * 2. A search index cache retrieved via storage.getSearchIndex()
485
+ *
486
+ * If a model's indexes are already cached, they won't be fetched again.
487
+ * The method uses the internal storage interface to retrieve the indexes.
487
488
  */
488
- #getLinksFor(model) {
489
- return Object.fromEntries(
490
- Object.entries(this.#getAllModelLinks())
491
- .filter(([modelName, links]) =>
492
- model.name === modelName ||
493
- Object.values(links).some((link) => link.name === model.name),
494
- ),
495
- );
489
+ async #getModelIndexes(modelsToCheck) {
490
+ const indexCache = new Map();
491
+ const searchIndexCache = new Map();
492
+
493
+ for (const [[modelName]] of modelsToCheck) {
494
+ if (!indexCache.has(modelName)) {
495
+ indexCache.set(modelName, await this.#storage.getIndex(this.#models.get(modelName)));
496
+ }
497
+
498
+ if (!searchIndexCache.has(modelName)) {
499
+ searchIndexCache.set(modelName, await this.#storage.getSearchIndex(this.#models.get(modelName)));
500
+ }
501
+ }
502
+
503
+ return [indexCache, searchIndexCache];
496
504
  }
497
505
 
498
506
  /**
499
- * Get all model links
500
- * @return {Record<string, Record<string, Model.constructor>>}
507
+ * Finds all model classes that are linked to the specified subject model.
508
+ *
509
+ * @private
510
+ * @param {Object} subject - The subject model instance to find linked model classes for.
511
+ * @param {string} subject.id - The ID of the subject model, used to identify its constructor.
512
+ *
513
+ * @returns {Map<Array<string|'one'|'many'|'up'|'down'>, Function>} A map where:
514
+ * - Keys are arrays with the format [modelName, propertyName, cardinality, direction]
515
+ * - modelName: The name of the linked model class
516
+ * - propertyName: The property name where the link is defined
517
+ * - cardinality: Either 'one' (one-to-one) or 'many' (one-to-many)
518
+ * - direction: 'down' for links defined in the subject model pointing to other models
519
+ * 'up' for links defined in other models pointing to the subject
520
+ * - Values are the model constructor functions for the linked classes
521
+ *
522
+ * @description
523
+ * This method identifies four types of relationships:
524
+ * 1. One-to-one links from subject to other models ('one', 'down')
525
+ * 2. One-to-many links from subject to other models ('many', 'down')
526
+ * 3. One-to-one links from other models to subject ('one', 'up')
527
+ * 4. One-to-many links from other models to subject ('many', 'up')
501
528
  */
502
- #getAllModelLinks() {
503
- return Object.entries(this.#models)
504
- .map(([registeredModelName, registeredModelClass]) =>
505
- Object.entries(registeredModelClass)
506
- .map(([propertyName, propertyProperty]) => [
507
- registeredModelName,
508
- propertyName,
509
- typeof propertyProperty === 'function' &&
510
- !/^class/.test(Function.prototype.toString.call(propertyProperty)) &&
511
- !Model.isModel(propertyProperty) ?
512
- propertyProperty() : propertyProperty,
513
- ])
514
- .filter(([_m, _p, type]) => Model.isModel(type))
515
- .map(([containingModel, propertyName, propertyProperty]) => ({
516
- containingModel,
517
- propertyName,
518
- propertyProperty,
519
- })),
520
- )
521
- .flat()
522
- .reduce((accumulator, {containingModel, propertyName, propertyProperty}) => {
523
- accumulator[containingModel] = accumulator[containingModel] ?? {};
524
- accumulator[containingModel][propertyName] = propertyProperty;
525
- return accumulator;
526
- }, {});
529
+ #findLinkedModelClasses(subject) {
530
+ const subjectModelConstructor = this.#getModelConstructorFromId(subject.id);
531
+ const modelsThatLinkToThisSubject = new Map();
532
+
533
+ for (const [propertyName, propertyType] of Object.entries(subjectModelConstructor)) {
534
+ // The model is a one to one link
535
+ if (propertyType.prototype instanceof Model) {
536
+ modelsThatLinkToThisSubject.set([propertyType.name, propertyName, 'one', 'down'], propertyType);
537
+ }
538
+ // The model is a one to many link
539
+
540
+ if (propertyType._items?.prototype instanceof Model) {
541
+ modelsThatLinkToThisSubject.set([propertyType._items.name, propertyName, 'many', 'down'], propertyType);
542
+ }
543
+ }
544
+
545
+ for (const [modelName, modelConstructor] of this.#models) {
546
+ for (const [propertyName, propertyType] of Object.entries(modelConstructor.properties)) {
547
+ // The model is a one to one link
548
+ if (propertyType === subjectModelConstructor || propertyType.prototype instanceof subjectModelConstructor) {
549
+ modelsThatLinkToThisSubject.set([modelName, propertyName, 'one', 'up'], propertyType);
550
+ }
551
+
552
+ // The model is a one to many link
553
+ if (propertyType._items === subjectModelConstructor || propertyType._items?.prototype instanceof subjectModelConstructor) {
554
+ modelsThatLinkToThisSubject.set([modelName, propertyName, 'many', 'up'], propertyType);
555
+ }
556
+ }
557
+ }
558
+
559
+ return modelsThatLinkToThisSubject;
527
560
  }
561
+ }
528
562
 
529
- /**
530
- * Get model instance that are directly linked to the given model in either direction
531
- * @param {Model} model
532
- * @param {object} cache
533
- * @return {Record<string, Record<string, Model>>}
534
- */
535
- async #getInstancesLinkedTo(model, cache) {
536
- return Object.fromEntries(
537
- Object.entries(
538
- await Promise.all(
539
- Object.entries(this.#getLinksFor(model.constructor))
540
- .map(([name, _index]) =>
541
- cache[name] ? Promise.resolve([name, Object.values(cache[name])]) :
542
- this.#storage.getIndex(this.#models[name])
543
- .then(i => {
544
- cache[name] = i;
545
- return [name, Object.values(i)];
546
- }),
547
- ),
548
- ).then(Object.fromEntries),
549
- ).map(([name, index]) => [
550
- name,
551
- index.map(item => Object.fromEntries(
552
- Object.entries(item)
553
- .filter(([propertyName, property]) => propertyName === 'id' || property?.id === model.id),
554
- )).filter(item => Object.keys(item).length > 1),
555
- ]),
556
- );
563
+ /**
564
+ * Resolves model properties that are factory functions to their class values.
565
+ *
566
+ * @private
567
+ * @param {Object<string, Function>} models - An object mapping model names to model constructor functions
568
+ * @returns {Map<string, Function>} A map of model names to model constructors with all factory
569
+ * function properties class to their bare model instances
570
+ *
571
+ * @description
572
+ * This method processes each property of each model constructor to resolve any factory functions.
573
+ * It skips:
574
+ * - Special properties like 'indexedProperties', 'searchProperties', and '_required'
575
+ * - Properties that are already bare model instances (have a prototype inheriting from Model)
576
+ * - Properties with a defined '_type' (basic types)
577
+ *
578
+ * For all other properties (assumed to be factory functions), it calls the function to get
579
+ * the class value and updates the model constructor.
580
+ */
581
+ function resolveModels(models) {
582
+ const resolvedToBareModels = new Map();
583
+
584
+ for (const [modelName, modelConstructor] of Object.entries(models)) {
585
+ for (const [propertyName, propertyType] of Object.entries(modelConstructor)) {
586
+ // The property is a builtin
587
+ if ([
588
+ 'indexedProperties',
589
+ 'searchProperties',
590
+ '_required',
591
+ ].includes(propertyName)) {
592
+ continue;
593
+ }
594
+
595
+ // The property is a bare model
596
+ if (propertyType.prototype instanceof Model) {
597
+ continue;
598
+ }
599
+
600
+ // The property is a basic type
601
+ if (propertyType._type) {
602
+ continue;
603
+ }
604
+
605
+ modelConstructor[propertyName] = propertyType();
606
+ }
607
+
608
+ resolvedToBareModels.set(modelName, modelConstructor);
557
609
  }
610
+
611
+ return resolvedToBareModels;
558
612
  }
559
613
 
560
614
  /**
package/src/Schema.js CHANGED
@@ -39,7 +39,7 @@ class Schema {
39
39
  const thisSchema = {};
40
40
 
41
41
  if (Model.isModel(schemaSegment)) {
42
- thisSchema.required = ['id'];
42
+ thisSchema.required = [];
43
43
  thisSchema.type = 'object';
44
44
  thisSchema.additionalProperties = false;
45
45
  thisSchema.properties = {
@@ -62,7 +62,7 @@ class Schema {
62
62
  thisSchema.properties[name] = {
63
63
  type: 'object',
64
64
  additionalProperties: false,
65
- required: ['id'],
65
+ required: [],
66
66
  properties: {
67
67
  id: {
68
68
  type: 'string',
package/src/data/Model.js CHANGED
@@ -163,7 +163,7 @@ class Model {
163
163
  static _required = true;
164
164
  }
165
165
 
166
- Object.defineProperty(Required, 'name', {value: `${this.toString()}`});
166
+ Object.defineProperty(Required, 'name', {value: this.name});
167
167
 
168
168
  return Required;
169
169
  }
@@ -192,11 +192,17 @@ class Model {
192
192
  */
193
193
  static indexedPropertiesResolved() {
194
194
  return [
195
- ...Object.entries(this)
196
- .filter(([_name, type]) => !type._type && (this.isModel(type) || this.isModel(type())))
195
+ ...Object.entries(this.properties)
196
+ .filter(([name, type]) => !['indexedProperties', 'searchProperties'].includes(name) && !type._type && (this.isModel(type) || (typeof type === 'function' && this.isModel(type()))))
197
197
  .map(([name, _type]) => `${name}.id`),
198
- ...Object.entries(this)
199
- .filter(([_name, type]) => !type._type && !this.isModel(type) && !type._items?._type && (this.isModel(type._items) || this.isModel(type()._items)))
198
+ ...Object.entries(this.properties)
199
+ .filter(([_name, type]) => {
200
+ return !Model.isModel(type) && (
201
+ (type._type === 'array' && this.isModel(type._items))
202
+ ||
203
+ (!type._type && typeof type === 'function' && this.isModel(type()._items))
204
+ );
205
+ })
200
206
  .map(([name, _type]) => `${name}.[*].id`),
201
207
  ...this.indexedProperties(),
202
208
  'id',
@@ -295,6 +301,51 @@ class Model {
295
301
  static withName(name) {
296
302
  Object.defineProperty(this, 'name', {value: name});
297
303
  }
304
+
305
+ /**
306
+ * Discover model properties all the way up the prototype chain.
307
+ *
308
+ * @return {Model}
309
+ */
310
+ static get properties() {
311
+ const props = {};
312
+ const chain = [];
313
+
314
+ let current = this;
315
+ while (current !== Function.prototype) {
316
+ chain.push(current);
317
+ current = Object.getPrototypeOf(current);
318
+ }
319
+
320
+ for (const item of chain) {
321
+ for (const property of Object.getOwnPropertyNames(item)) {
322
+ if (
323
+ [
324
+ '_required',
325
+ 'fromData',
326
+ 'indexedProperties',
327
+ 'indexedPropertiesResolved',
328
+ 'isDryModel',
329
+ 'isModel',
330
+ 'length',
331
+ 'name',
332
+ 'properties',
333
+ 'prototype',
334
+ 'required',
335
+ 'searchProperties',
336
+ 'toString',
337
+ 'withName',
338
+ ].includes(property)
339
+ ) continue;
340
+
341
+ if (Object.keys(props).includes(property)) continue;
342
+
343
+ props[property] = item[property];
344
+ }
345
+ }
346
+
347
+ return Object.assign(this, props);
348
+ }
298
349
  }
299
350
 
300
351
  export default Model;
@@ -5,6 +5,7 @@ import DateType from './properties/DateType.js';
5
5
  import NumberType from './properties/NumberType.js';
6
6
  import SlugType from './properties/SlugType.js';
7
7
  import StringType from './properties/StringType.js';
8
+ import Type from './properties/Type.js';
8
9
 
9
10
  const Property = {
10
11
  Array: ArrayType,
@@ -14,6 +15,7 @@ const Property = {
14
15
  Number: NumberType,
15
16
  Slug: SlugType,
16
17
  String: StringType,
18
+ Type,
17
19
  };
18
20
 
19
21
  export default Property;
@@ -66,7 +66,7 @@ class Type {
66
66
  }
67
67
 
68
68
  // Define the class name as "Required<OriginalTypeName>"
69
- Object.defineProperty(Required, 'name', {value: `Required${this.toString()}`});
69
+ Object.defineProperty(Required, 'name', {value: `Required${this.name}`});
70
70
 
71
71
  return Required;
72
72
  }
@@ -132,7 +132,9 @@ export class ModelNotFoundStorageEngineError extends StorageEngineError {
132
132
  export class DeleteHasUnintendedConsequencesStorageEngineError extends StorageEngineError {
133
133
  /**
134
134
  * @param {string} modelId
135
- * @param {object} consequences
135
+ * @param {Object} consequences
136
+ * @param {Array<String>?} consequences.willDelete
137
+ * @param {Array<String>?} consequences.willUpdate
136
138
  */
137
139
  constructor(modelId, consequences) {
138
140
  super(`Deleting ${modelId} has unintended consequences`);