@acodeninja/persist 3.0.0-next.8 → 3.0.0

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.
Files changed (41) hide show
  1. package/README.md +60 -4
  2. package/docs/code-quirks.md +14 -14
  3. package/docs/defining-models.md +61 -0
  4. package/docs/http.openapi.yml +138 -0
  5. package/docs/{model-property-types.md → model-properties.md} +76 -43
  6. package/docs/models-as-properties.md +46 -46
  7. package/docs/search-queries.md +11 -13
  8. package/docs/storage-engines.md +19 -35
  9. package/docs/structured-queries.md +59 -48
  10. package/docs/transactions.md +6 -7
  11. package/exports/storage/http.js +3 -0
  12. package/exports/storage/s3.js +3 -0
  13. package/jest.config.cjs +8 -12
  14. package/package.json +2 -2
  15. package/src/Connection.js +750 -0
  16. package/src/Persist.js +29 -30
  17. package/src/Schema.js +175 -0
  18. package/src/{Query.js → data/FindIndex.js} +40 -24
  19. package/src/{type → data}/Model.js +95 -55
  20. package/src/data/Property.js +21 -0
  21. package/src/data/SearchIndex.js +106 -0
  22. package/src/{type/complex → data/properties}/ArrayType.js +5 -3
  23. package/src/{type/simple → data/properties}/BooleanType.js +3 -3
  24. package/src/{type/complex → data/properties}/CustomType.js +5 -5
  25. package/src/{type/simple → data/properties}/DateType.js +4 -4
  26. package/src/{type/simple → data/properties}/NumberType.js +3 -3
  27. package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
  28. package/src/{type/resolved → data/properties}/SlugType.js +1 -1
  29. package/src/{type/simple → data/properties}/StringType.js +3 -3
  30. package/src/{type → data/properties}/Type.js +13 -3
  31. package/src/engine/storage/HTTPStorageEngine.js +149 -253
  32. package/src/engine/storage/S3StorageEngine.js +108 -195
  33. package/src/engine/storage/StorageEngine.js +131 -549
  34. package/exports/engine/storage/file.js +0 -3
  35. package/exports/engine/storage/http.js +0 -3
  36. package/exports/engine/storage/s3.js +0 -3
  37. package/src/SchemaCompiler.js +0 -196
  38. package/src/Transactions.js +0 -145
  39. package/src/engine/StorageEngine.js +0 -350
  40. package/src/engine/storage/FileStorageEngine.js +0 -213
  41. package/src/type/index.js +0 -32
@@ -0,0 +1,750 @@
1
+ import {
2
+ CreateTransactionalStorageEngine,
3
+ DeleteHasUnintendedConsequencesStorageEngineError,
4
+ } from './engine/storage/StorageEngine.js';
5
+ import FindIndex from './data/FindIndex.js';
6
+ import Model from './data/Model.js';
7
+ import SearchIndex from './data/SearchIndex.js';
8
+ import _ from 'lodash';
9
+
10
+ /**
11
+ * @class State
12
+ */
13
+ class State {
14
+ /**
15
+ * @private
16
+ * @property {StorageEngine}
17
+ */
18
+ #storage;
19
+
20
+ constructor(storage) {
21
+ this.modelCache = new Map();
22
+ this.indexCache = new Map();
23
+ this.searchIndexCache = new Map();
24
+ this.#storage = storage;
25
+ }
26
+
27
+ /**
28
+ * Get a given index
29
+ * @param {Model.constructor} modelConstructor
30
+ * @return {Object}
31
+ */
32
+ async getIndex(modelConstructor) {
33
+ const modelConstructorName = modelConstructor.name;
34
+
35
+ if (!this.indexCache.has(modelConstructorName)) {
36
+ this.indexCache.set(modelConstructorName, {
37
+ changed: false,
38
+ index: await this.#storage.getIndex(modelConstructor),
39
+ });
40
+ }
41
+
42
+ return this.indexCache.get(modelConstructorName)?.index;
43
+ }
44
+
45
+ /**
46
+ * Get a Map of indexes that have been tainted
47
+ * @return {Map<String, Object>}
48
+ */
49
+ getTaintedIndexes() {
50
+ return new Map(
51
+ this.indexCache
52
+ .entries()
53
+ .filter(([_name, cache]) => cache.changed)
54
+ .map(([name, {index}]) => [name, index]),
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Update a given index
60
+ * @param {string} modelConstructorName
61
+ * @return {Object}
62
+ */
63
+ updateIndex(modelConstructorName, index) {
64
+ this.indexCache.set(modelConstructorName, {index, changed: true});
65
+ }
66
+
67
+ /**
68
+ * Get a given search index
69
+ * @param {Model.constructor} modelConstructor
70
+ * @return {Object}
71
+ */
72
+ async getSearchIndex(modelConstructor) {
73
+ const modelConstructorName = modelConstructor.name;
74
+
75
+ if (!this.searchIndexCache.has(modelConstructorName)) {
76
+ this.searchIndexCache.set(modelConstructorName, {
77
+ changed: false,
78
+ index: await this.#storage.getSearchIndex(modelConstructor),
79
+ });
80
+ }
81
+
82
+ return this.searchIndexCache.get(modelConstructorName)?.index;
83
+ }
84
+
85
+ /**
86
+ * Get a Map of search indexes that have been tainted
87
+ * @return {Map<String, Object>}
88
+ */
89
+ getTaintedSearchIndexes() {
90
+ return new Map(
91
+ this.searchIndexCache
92
+ .entries()
93
+ .filter(([_name, cache]) => cache.changed)
94
+ .map(([name, {index}]) => [name, index]),
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Update a given search index
100
+ * @param {string} modelConstructorName
101
+ * @return {Object}
102
+ */
103
+ updateSearchIndex(modelConstructorName, index) {
104
+ this.searchIndexCache.set(modelConstructorName, {index, changed: true});
105
+ }
106
+ }
107
+
108
+ /**
109
+ * @class Connection
110
+ */
111
+ export default class Connection {
112
+ /**
113
+ * @private
114
+ * @property {StorageEngine}
115
+ */
116
+ #storage;
117
+
118
+ /**
119
+ * @private
120
+ * @property {Record<String, Model.constructor>}
121
+ */
122
+ #models;
123
+
124
+ /**
125
+ * Create a new connection
126
+ * @param {StorageEngine} storage
127
+ * @param {Array<Model.constructor>} models
128
+ */
129
+ constructor(storage, models) {
130
+ this.#storage = storage;
131
+ this.#models = resolveModels(Object.fromEntries((models ?? []).map(model => [model.name, model])));
132
+
133
+ if (!this.#storage) throw new MissingArgumentsConnectionError('No storage engine provided');
134
+ }
135
+
136
+ /**
137
+ * Get a model by its id
138
+ * @param {String} modelId
139
+ * @throws {ModelNotRegisteredConnectionError}
140
+ * @return {Promise<Model>}
141
+ */
142
+ async get(modelId) {
143
+ const modelConstructor = this.#getModelConstructorFromId(modelId);
144
+
145
+ const data = await this.#storage.getModel(modelId);
146
+
147
+ return modelConstructor.fromData(data);
148
+ }
149
+
150
+ /**
151
+ * Accepts a dry model, for example:
152
+ *
153
+ * - an object with only an id property
154
+ * - a model missing linked model fields
155
+ * - a model missing non-indexed properties
156
+ *
157
+ * and fetches all data for a model.
158
+ * @param {Object} dryModel
159
+ * @param {Map?} modelCache
160
+ * @return {Promise<Model>}
161
+ */
162
+ async hydrate(dryModel, modelCache = new Map()) {
163
+ /**
164
+ * Recursively hydrates a single model and its nested properties.
165
+ *
166
+ * @param {Object|Model} modelToProcess - The model instance to hydrate.
167
+ * @returns {Promise<Model>} The hydrated model instance.
168
+ */
169
+ const hydrateModel = async (modelToProcess) => {
170
+ modelCache.set(modelToProcess.id, modelToProcess);
171
+
172
+ for (const [name, property] of Object.entries(modelToProcess)) {
173
+ if (Model.isDryModel(property)) {
174
+ // skipcq: JS-0129
175
+ modelToProcess[name] = await hydrateSubModel(property);
176
+ } else if (Array.isArray(property) && property.length && Model.isDryModel(property[0])) {
177
+ // skipcq: JS-0129
178
+ modelToProcess[name] = await hydrateModelList(property);
179
+ }
180
+ }
181
+
182
+ modelCache.set(modelToProcess.id, modelToProcess);
183
+
184
+ return modelToProcess;
185
+ };
186
+
187
+ /**
188
+ * Hydrates a nested sub-model if it hasn't already been hydrated.
189
+ *
190
+ * @param {Object} property - The sub-model with a known ID but incomplete data.
191
+ * @returns {Promise<Model>} The fully hydrated sub-model.
192
+ */
193
+ const hydrateSubModel = async (property) => {
194
+ if (modelCache.has(property.id)) return modelCache.get(property.id);
195
+
196
+ const subModel = await this.get(property.id);
197
+
198
+ const hydratedSubModel = await hydrateModel(subModel);
199
+ modelCache.set(property.id, hydratedSubModel);
200
+ return hydratedSubModel;
201
+ };
202
+
203
+ /**
204
+ * Hydrates a list of related sub-models.
205
+ *
206
+ * @param {Array<Object>} property - Array of dry sub-models.
207
+ * @returns {Promise<Array<Model>>} Array of hydrated sub-models.
208
+ */
209
+ const hydrateModelList = async (property) => {
210
+ const newModelList = await Promise.all(property.map(subModel => {
211
+ if (modelCache.has(subModel.id)) return modelCache.get(subModel.id);
212
+
213
+ return this.get(subModel.id);
214
+ }));
215
+
216
+ return Promise.all(newModelList.map(async subModel => {
217
+ if (modelCache.has(subModel.id)) return modelCache.get(subModel.id);
218
+
219
+ const hydratedSubModel = await hydrateModel(subModel);
220
+
221
+ modelCache.set(hydratedSubModel.id, hydratedSubModel);
222
+
223
+ return hydratedSubModel;
224
+ }));
225
+ };
226
+
227
+ return hydrateModel(await this.get(dryModel.id));
228
+ }
229
+
230
+ /**
231
+ * Persists a model if it has changed, and updates all related models and their indexes
232
+ * @param {Model} model
233
+ * @return {Promise<void>}
234
+ * @throws {ValidationError|ModelNotRegisteredConnectionError}
235
+ */
236
+ async put(model) {
237
+ const processedModels = [];
238
+ const modelsToPut = [];
239
+ const modelsToReindex = {};
240
+ const modelsToReindexSearch = {};
241
+
242
+ /**
243
+ * @param {Model} modelToProcess
244
+ * @return {Promise<void>}
245
+ */
246
+ const processModel = async (modelToProcess) => {
247
+ if (processedModels.includes(modelToProcess.id))
248
+ return;
249
+
250
+ processedModels.push(modelToProcess.id);
251
+
252
+ if (!this.#models.has(modelToProcess.constructor.name))
253
+ throw new ModelNotRegisteredConnectionError(modelToProcess, this.#storage);
254
+
255
+ modelToProcess.validate();
256
+
257
+ const modelToProcessConstructor = this.#getModelConstructorFromId(modelToProcess.id);
258
+ const currentModel = await this.hydrate(modelToProcess).catch(() => null);
259
+
260
+ const modelToProcessHasChanged = !_.isEqual(currentModel?.toData() || {}, modelToProcess.toData());
261
+
262
+ if (modelToProcessHasChanged) modelsToPut.push(modelToProcess);
263
+
264
+ const modelToProcessConstructorName = modelToProcessConstructor.name;
265
+
266
+ if (
267
+ Boolean(modelToProcess.constructor.indexedProperties().length) &&
268
+ (!currentModel || !_.isEqual(currentModel.toIndexData(), modelToProcess.toIndexData()))
269
+ ) {
270
+ modelsToReindex[modelToProcessConstructorName] = modelsToReindex[modelToProcessConstructorName] || [];
271
+ modelsToReindex[modelToProcessConstructorName].push(modelToProcess);
272
+ }
273
+
274
+ if (
275
+ Boolean(modelToProcess.constructor.searchProperties().length) &&
276
+ (!currentModel || !_.isEqual(currentModel.toSearchData(), modelToProcess.toSearchData()))
277
+ ) {
278
+ modelsToReindexSearch[modelToProcessConstructorName] = modelsToReindexSearch[modelToProcessConstructorName] || [];
279
+ modelsToReindexSearch[modelToProcessConstructorName].push(modelToProcess);
280
+ }
281
+
282
+ for (const [_name, property] of Object.entries(modelToProcess)) {
283
+ if (Model.isModel(property)) {
284
+ await processModel(property);
285
+ } else if (Array.isArray(property) && Model.isModel(property[0])) {
286
+ await Promise.all(property.map(processModel));
287
+ }
288
+ }
289
+ };
290
+
291
+ await processModel(model);
292
+
293
+ await Promise.all([
294
+ Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
295
+ Promise.all(Object.entries(modelsToReindex).map(async ([constructorName, models]) => {
296
+ const modelConstructor = this.#models.get(constructorName);
297
+ const index = await this.#storage.getIndex(modelConstructor);
298
+
299
+ await this.#storage.putIndex(modelConstructor, {
300
+ ...index,
301
+ ...Object.fromEntries(models.map(m => [m.id, m.toIndexData()])),
302
+ });
303
+ })),
304
+ Promise.all(Object.entries(modelsToReindexSearch).map(async ([constructorName, models]) => {
305
+ const modelConstructor = this.#models.get(constructorName);
306
+ const index = await this.#storage.getSearchIndex(modelConstructor);
307
+
308
+ await this.#storage.putSearchIndex(modelConstructor, {
309
+ ...index,
310
+ ...Object.fromEntries(models.map(m => [m.id, m.toSearchData()])),
311
+ });
312
+ })),
313
+ ]);
314
+ }
315
+
316
+ /**
317
+ * Delete a model and update indexes that reference it
318
+ * @param {Model} subject
319
+ * @param {Array<string>} propagateTo - List of model ids that are expected to be deleted or updated.
320
+ * @throws {ModelNotRegisteredConnectionError}
321
+ * @throws {ModelNotFoundStorageEngineError}
322
+ */
323
+ async delete(subject, propagateTo = []) {
324
+ const state = new State(this.#storage);
325
+ const modelsToCheck = this.#findLinkedModelClasses(subject);
326
+ const modelsToDelete = new Set([subject.id]);
327
+ const modelsToUpdate = new Set();
328
+ const indexesToUpdate = new Set();
329
+ const searchIndexesToUpdate = new Set();
330
+
331
+ subject = await this.hydrate(subject, state.modelCache);
332
+
333
+ if (!propagateTo.includes(subject.id)) {
334
+ propagateTo.push(subject.id);
335
+ }
336
+
337
+ // Populate model cache
338
+ for (const [[modelName, propertyName, type, direction], _modelConstructor] of modelsToCheck) {
339
+ const query = {};
340
+
341
+ if (direction === 'up') {
342
+ if (type === 'one') {
343
+ query[propertyName] = {id: {$is: subject.id}};
344
+ }
345
+
346
+ if (type === 'many') {
347
+ query[propertyName] = {
348
+ $contains: {
349
+ id: {$is: subject.id},
350
+ },
351
+ };
352
+ }
353
+ }
354
+
355
+ const foundModels =
356
+ _.isEqual(query, {}) ?
357
+ (
358
+ Array.isArray(subject[propertyName]) ?
359
+ subject[propertyName] : [subject[propertyName]]
360
+ ) : new FindIndex(this.#models.get(modelName), await state.getIndex(this.#models.get(modelName))).query(query);
361
+
362
+ for (const foundModel of foundModels) {
363
+ if (!state.modelCache.has(foundModel.id)) {
364
+ state.modelCache.set(foundModel.id, await this.hydrate(foundModel, state.modelCache));
365
+ }
366
+ }
367
+
368
+ // for deletes, update models that link to the subject
369
+ if (direction === 'up') {
370
+ if (type === 'one') {
371
+ for (const foundModel of foundModels) {
372
+ const cachedModel = state.modelCache.get(foundModel.id);
373
+
374
+ if (foundModel.constructor[propertyName]._required) {
375
+ modelsToDelete.add(foundModel.id);
376
+ continue;
377
+ }
378
+
379
+ cachedModel[propertyName] = undefined;
380
+ state.modelCache.set(foundModel.id, cachedModel);
381
+ modelsToUpdate.add(foundModel.id);
382
+ }
383
+ }
384
+
385
+ if (type === 'many') {
386
+ for (const foundModel of foundModels) {
387
+ const cachedModel = state.modelCache.get(foundModel.id);
388
+
389
+ cachedModel[propertyName] = cachedModel[propertyName].filter(m => m.id !== subject.id);
390
+ state.modelCache.set(foundModel.id, cachedModel);
391
+ modelsToUpdate.add(foundModel.id);
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ const unrequestedDeletions = [...modelsToDelete].filter(id => !propagateTo.includes(id));
398
+ const unrequestedUpdates = [...modelsToUpdate].filter(id => !propagateTo.includes(id));
399
+
400
+ if (unrequestedDeletions.length || unrequestedUpdates.length) {
401
+ throw new DeleteHasUnintendedConsequencesStorageEngineError(subject.id, {
402
+ willDelete: unrequestedDeletions.map(id => state.modelCache.get(id)),
403
+ willUpdate: unrequestedUpdates.map(id => state.modelCache.get(id)),
404
+ });
405
+ }
406
+
407
+ await Promise.all(
408
+ new Set([...modelsToDelete, ...modelsToUpdate].map(this.#getModelConstructorFromId.bind(this)))
409
+ .values()
410
+ .map(async modelConstructor => {
411
+ await state.getIndex(modelConstructor);
412
+ indexesToUpdate.add(modelConstructor.name);
413
+
414
+ if (modelConstructor.searchProperties().length) {
415
+ await state.getSearchIndex(modelConstructor);
416
+ searchIndexesToUpdate.add(modelConstructor.name);
417
+ }
418
+ }),
419
+ );
420
+
421
+ for (const indexName of searchIndexesToUpdate) {
422
+ const index = await state.getSearchIndex(this.#models.get(indexName));
423
+
424
+ for (const model of [...modelsToUpdate].filter(i => i.startsWith(indexName))) {
425
+ index[model] = state.modelCache.get(model).toSearchData();
426
+ }
427
+
428
+ for (const model of [...modelsToDelete].filter(i => i.startsWith(indexName))) {
429
+ delete index[model];
430
+ }
431
+
432
+ state.updateSearchIndex(indexName, index);
433
+ }
434
+
435
+ for (const indexName of indexesToUpdate) {
436
+ const index = await state.getIndex(this.#models.get(indexName));
437
+
438
+ for (const model of [...modelsToUpdate].filter(i => i.startsWith(indexName))) {
439
+ index[model] = state.modelCache.get(model).toIndexData();
440
+ }
441
+
442
+ for (const model of [...modelsToDelete].filter(i => i.startsWith(indexName))) {
443
+ delete index[model];
444
+ }
445
+
446
+ state.updateIndex(indexName, index);
447
+ }
448
+
449
+ await Promise.all([
450
+ Promise.all([...modelsToUpdate].map(id => this.#storage.putModel(state.modelCache.get(id).toData()))),
451
+ Promise.all([...modelsToDelete].map(id => this.#storage.deleteModel(id))),
452
+ Promise.all(state
453
+ .getTaintedIndexes()
454
+ .entries()
455
+ .map(([modelConstructorName, index]) =>
456
+ this.#storage.putIndex(this.#models.get(modelConstructorName), index),
457
+ ),
458
+ ),
459
+ Promise.all(state
460
+ .getTaintedSearchIndexes()
461
+ .entries()
462
+ .map(([modelConstructorName, index]) =>
463
+ this.#storage.putSearchIndex(this.#models.get(modelConstructorName), index),
464
+ ),
465
+ ),
466
+ ]);
467
+ }
468
+
469
+ /**
470
+ * Search the given model's search properties for matching results.
471
+ * Wildcards: character '*' can be placed at any location in a query
472
+ * Fields: search for a specific field's value with 'field:value'
473
+ * Boosting: if foo is important try 'foo^10 bar'
474
+ * Fuzzy: 'foo~1' will match 'boo' but not 'bao', 'foo~2' would match 'bao'
475
+ * Must include: '+foo bar' must include 'foo' and may include 'bar'
476
+ * Must not include: '-foo bar' must not include 'foo' and may include 'bar'
477
+ * Mixed include: '+foo -bar' must include 'foo' must not include 'bar'
478
+ * @param {Model.constructor} modelConstructor
479
+ * @param {string} query
480
+ * @return {Promise<Array<SearchResult>>}
481
+ */
482
+ async search(modelConstructor, query) {
483
+ const searchIndex = await this.#storage.getSearchIndex(modelConstructor)
484
+ .then(index => new SearchIndex(modelConstructor, index));
485
+
486
+ return searchIndex.search(query);
487
+ }
488
+
489
+ /**
490
+ * Find using a structured query and indexed fields.
491
+ *
492
+ * @param {Model.constructor} modelConstructor
493
+ * @param {Object} query
494
+ * @return {Promise<Array<SearchResult>>}
495
+ */
496
+ async find(modelConstructor, query) {
497
+ const findIndex = await this.#storage.getIndex(modelConstructor)
498
+ .then(index => new FindIndex(modelConstructor, index));
499
+
500
+ return findIndex.query(query);
501
+ }
502
+
503
+ /**
504
+ * Start a transaction, allowing multiple queries to be queued up and committed in one go.
505
+ * Should an error occur, any committed operations will be reverted.
506
+ * @return {Connection}
507
+ */
508
+ transaction() {
509
+ const operations = [];
510
+
511
+ const engine = CreateTransactionalStorageEngine(operations, this.#storage);
512
+
513
+ const transaction = new this.constructor(engine, [...this.#models.values()]);
514
+
515
+ transaction.commit = async () => {
516
+ try {
517
+ for (const [index, operation] of operations.entries()) {
518
+ try {
519
+ if (operation.method === 'putModel')
520
+ operations[index].original = await this.#storage.getModel(operation.args[0].id).catch(() => undefined);
521
+
522
+ if (operation.method === 'deleteModel')
523
+ operations[index].original = await this.#storage.getModel(operation.args[0]);
524
+
525
+ if (operation.method === 'putIndex')
526
+ operations[index].original = await this.#storage.getIndex(operation.args[0]);
527
+
528
+ if (operation.method === 'putSearchIndex')
529
+ operations[index].original = await this.#storage.getSearchIndex(operation.args[0]);
530
+
531
+ await this.#storage[operation.method](...operation.args);
532
+
533
+ operations[index].committed = true;
534
+ } catch (error) {
535
+ operations[index].error = error;
536
+ throw error;
537
+ }
538
+ }
539
+ } catch (error) {
540
+ for (const operation of operations.slice().reverse()) {
541
+ if (operation.committed && operation.original) {
542
+ if (['putModel', 'deleteModel'].includes(operation.method))
543
+ await this.#storage.putModel(operation.original);
544
+
545
+ if (operation.method === 'putIndex')
546
+ await this.#storage.putIndex(operation.args[0], operation.original);
547
+
548
+ if (operation.method === 'putSearchIndex')
549
+ await this.#storage.putSearchIndex(operation.args[0], operation.original);
550
+ }
551
+
552
+ if (operation.method === 'putModel' && operation.committed && !operation.original) {
553
+ await this.#storage.deleteModel(operation.args[0].id);
554
+ }
555
+ }
556
+
557
+ throw new CommitFailedTransactionError(operations, error);
558
+ }
559
+ };
560
+
561
+ return transaction;
562
+ }
563
+
564
+ /**
565
+ * Get the model constructor from a model id
566
+ * @param {String} modelId
567
+ * @throws ModelNotRegisteredConnectionError
568
+ * @return Model.constructor
569
+ */
570
+ #getModelConstructorFromId(modelId) {
571
+ const modelName = modelId.split('/')[0];
572
+ const modelConstructor = this.#models.get(modelName);
573
+
574
+ if (!modelConstructor) throw new ModelNotRegisteredConnectionError(modelName, this.#storage);
575
+
576
+ return modelConstructor;
577
+ }
578
+
579
+ /**
580
+ * Finds all model classes that are linked to the specified subject model.
581
+ *
582
+ * @private
583
+ * @param {Object} subject - The subject model instance to find linked model classes for.
584
+ * @param {string} subject.id - The ID of the subject model, used to identify its constructor.
585
+ *
586
+ * @returns {Map<Array<string|'one'|'many'|'up'|'down'>, Function>} A map where:
587
+ * - Keys are arrays with the format [modelName, propertyName, cardinality, direction]
588
+ * - modelName: The name of the linked model class
589
+ * - propertyName: The property name where the link is defined
590
+ * - cardinality: Either 'one' (one-to-one) or 'many' (one-to-many)
591
+ * - direction: 'down' for links defined in the subject model pointing to other models
592
+ * 'up' for links defined in other models pointing to the subject
593
+ * - Values are the model constructor functions for the linked classes
594
+ *
595
+ * @description
596
+ * This method identifies four types of relationships:
597
+ * 1. One-to-one links from subject to other models ('one', 'down')
598
+ * 2. One-to-many links from subject to other models ('many', 'down')
599
+ * 3. One-to-one links from other models to subject ('one', 'up')
600
+ * 4. One-to-many links from other models to subject ('many', 'up')
601
+ */
602
+ #findLinkedModelClasses(subject) {
603
+ const subjectModelConstructor = this.#getModelConstructorFromId(subject.id);
604
+ const modelsThatLinkToThisSubject = new Map();
605
+
606
+ for (const [propertyName, propertyType] of Object.entries(subjectModelConstructor)) {
607
+ // The model is a one to one link
608
+ if (propertyType.prototype instanceof Model) {
609
+ modelsThatLinkToThisSubject.set([propertyType.name, propertyName, 'one', 'down'], propertyType);
610
+ }
611
+ // The model is a one to many link
612
+
613
+ if (propertyType._items?.prototype instanceof Model) {
614
+ modelsThatLinkToThisSubject.set([propertyType._items.name, propertyName, 'many', 'down'], propertyType);
615
+ }
616
+ }
617
+
618
+ for (const [modelName, modelConstructor] of this.#models) {
619
+ for (const [propertyName, propertyType] of Object.entries(modelConstructor.properties)) {
620
+ // The model is a one to one link
621
+ if (propertyType === subjectModelConstructor || propertyType.prototype instanceof subjectModelConstructor) {
622
+ modelsThatLinkToThisSubject.set([modelName, propertyName, 'one', 'up'], propertyType);
623
+ }
624
+
625
+ // The model is a one to many link
626
+ if (propertyType._items === subjectModelConstructor || propertyType._items?.prototype instanceof subjectModelConstructor) {
627
+ modelsThatLinkToThisSubject.set([modelName, propertyName, 'many', 'up'], propertyType);
628
+ }
629
+ }
630
+ }
631
+
632
+ return modelsThatLinkToThisSubject;
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Resolves model properties that are factory functions to their class values.
638
+ *
639
+ * @private
640
+ * @param {Object<string, Function>} models - An object mapping model names to model constructor functions
641
+ * @returns {Map<string, Function>} A map of model names to model constructors with all factory
642
+ * function properties class to their bare model instances
643
+ *
644
+ * @description
645
+ * This method processes each property of each model constructor to resolve any factory functions.
646
+ * It skips:
647
+ * - Special properties like 'indexedProperties', 'searchProperties', and '_required'
648
+ * - Properties that are already bare model instances (have a prototype inheriting from Model)
649
+ * - Properties with a defined '_type' (basic types)
650
+ *
651
+ * For all other properties (assumed to be factory functions), it calls the function to get
652
+ * the class value and updates the model constructor.
653
+ */
654
+ function resolveModels(models) {
655
+ const resolvedToBareModels = new Map();
656
+
657
+ for (const [modelName, modelConstructor] of Object.entries(models)) {
658
+ for (const [propertyName, propertyType] of Object.entries(modelConstructor)) {
659
+ // The property is a builtin
660
+ if ([
661
+ 'indexedProperties',
662
+ 'searchProperties',
663
+ '_required',
664
+ ].includes(propertyName)) {
665
+ continue;
666
+ }
667
+
668
+ // The property is a bare model
669
+ if (propertyType.prototype instanceof Model) {
670
+ continue;
671
+ }
672
+
673
+ // The property is a basic type
674
+ if (propertyType._type) {
675
+ continue;
676
+ }
677
+
678
+ modelConstructor[propertyName] = propertyType();
679
+ }
680
+
681
+ resolvedToBareModels.set(modelName, modelConstructor);
682
+ }
683
+
684
+ return resolvedToBareModels;
685
+ }
686
+
687
+ /**
688
+ * Base class for errors that occur during connection operations.
689
+ *
690
+ * @class ConnectionError
691
+ * @extends Error
692
+ */
693
+ export class ConnectionError extends Error {
694
+ }
695
+
696
+ /**
697
+ * Thrown when a connection is created with missing arguments.
698
+ *
699
+ * @class MissingArgumentsConnectionError
700
+ * @extends ConnectionError
701
+ */
702
+ export class MissingArgumentsConnectionError extends ConnectionError {
703
+ }
704
+
705
+ /**
706
+ * Thrown when a model class is not registered.
707
+ *
708
+ * @class ModelNotRegisteredConnectionError
709
+ * @extends ConnectionError
710
+ */
711
+ export class ModelNotRegisteredConnectionError extends ConnectionError {
712
+ /**
713
+ * @param {Model|String} modelConstructor
714
+ * @param {Connection} connection
715
+ */
716
+ constructor(modelConstructor, connection) {
717
+ const modelName = typeof modelConstructor === 'string' ? modelConstructor : modelConstructor.constructor.name;
718
+ super(`The model ${modelName} is not registered in the storage engine ${connection.constructor.name}`);
719
+ }
720
+ }
721
+
722
+ /**
723
+ * Base class for errors that occur during transactions.
724
+ *
725
+ * @class TransactionError
726
+ * @extends {Error}
727
+ */
728
+ class TransactionError extends Error {
729
+ }
730
+
731
+ /**
732
+ * Thrown when a transaction fails to commit.
733
+ *
734
+ * Contains the original error and the list of transactions involved.
735
+ *
736
+ * @class CommitFailedTransactionError
737
+ * @extends {TransactionError}
738
+ */
739
+ export class CommitFailedTransactionError extends TransactionError {
740
+ /**
741
+ *
742
+ * @param {Array<Operation>} transactions
743
+ * @param {Error} error
744
+ */
745
+ constructor(transactions, error) {
746
+ super('Operation failed to commit.');
747
+ this.transactions = transactions;
748
+ this.error = error;
749
+ }
750
+ }