@acodeninja/persist 3.0.0-next.9 → 3.0.1-next.1
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.
- package/README.md +60 -4
- package/docs/code-quirks.md +14 -14
- package/docs/defining-models.md +61 -0
- package/docs/http.openapi.yml +138 -0
- package/docs/{model-property-types.md → model-properties.md} +76 -43
- package/docs/models-as-properties.md +46 -46
- package/docs/search-queries.md +11 -13
- package/docs/storage-engines.md +19 -35
- package/docs/structured-queries.md +59 -48
- package/docs/transactions.md +6 -7
- package/exports/storage/http.js +3 -0
- package/exports/storage/s3.js +3 -0
- package/jest.config.cjs +8 -12
- package/package.json +2 -2
- package/src/Connection.js +750 -0
- package/src/Persist.js +29 -30
- package/src/Schema.js +175 -0
- package/src/{Query.js → data/FindIndex.js} +40 -24
- package/src/{type → data}/Model.js +95 -55
- package/src/data/Property.js +21 -0
- package/src/data/SearchIndex.js +106 -0
- package/src/{type/complex → data/properties}/ArrayType.js +5 -3
- package/src/{type/simple → data/properties}/BooleanType.js +3 -3
- package/src/{type/complex → data/properties}/CustomType.js +5 -5
- package/src/{type/simple → data/properties}/DateType.js +4 -4
- package/src/{type/simple → data/properties}/NumberType.js +3 -3
- package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
- package/src/{type/resolved → data/properties}/SlugType.js +1 -1
- package/src/{type/simple → data/properties}/StringType.js +3 -3
- package/src/{type → data/properties}/Type.js +13 -3
- package/src/engine/storage/HTTPStorageEngine.js +149 -253
- package/src/engine/storage/S3StorageEngine.js +108 -195
- package/src/engine/storage/StorageEngine.js +131 -549
- package/exports/engine/storage/file.js +0 -3
- package/exports/engine/storage/http.js +0 -3
- package/exports/engine/storage/s3.js +0 -3
- package/src/SchemaCompiler.js +0 -196
- package/src/Transactions.js +0 -145
- package/src/engine/StorageEngine.js +0 -472
- package/src/engine/storage/FileStorageEngine.js +0 -213
- 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(m => this.#getModelConstructorFromId(m)?.name === indexName)) {
|
425
|
+
index[model] = state.modelCache.get(model).toSearchData();
|
426
|
+
}
|
427
|
+
|
428
|
+
for (const model of [...modelsToDelete].filter(m => this.#getModelConstructorFromId(m)?.name === 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(m => this.#getModelConstructorFromId(m)?.name === indexName)) {
|
439
|
+
index[model] = state.modelCache.get(model).toIndexData();
|
440
|
+
}
|
441
|
+
|
442
|
+
for (const model of [...modelsToDelete].filter(m => this.#getModelConstructorFromId(m)?.name === 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
|
+
}
|