@acodeninja/persist 3.0.0-next.2 → 3.0.0-next.20
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 +6 -6
- package/docs/defining-models.md +61 -0
- package/docs/http.openapi.yml +138 -0
- package/docs/{model-property-types.md → model-properties.md} +37 -35
- package/docs/models-as-properties.md +40 -40
- package/docs/search-queries.md +3 -5
- package/docs/storage-engines.md +19 -35
- package/docs/structured-queries.md +56 -45
- 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 +631 -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 +41 -26
- package/src/data/Property.js +19 -0
- package/src/data/SearchIndex.js +106 -0
- package/src/{type/complex → data/properties}/ArrayType.js +1 -1
- package/src/{type/simple → data/properties}/BooleanType.js +1 -1
- package/src/{type/complex → data/properties}/CustomType.js +1 -1
- package/src/{type/simple → data/properties}/DateType.js +1 -1
- package/src/{type/simple → data/properties}/NumberType.js +1 -1
- package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
- package/src/{type/simple → data/properties}/StringType.js +1 -1
- package/src/{type → data/properties}/Type.js +8 -0
- package/src/engine/storage/HTTPStorageEngine.js +149 -253
- package/src/engine/storage/S3StorageEngine.js +108 -195
- package/src/engine/storage/StorageEngine.js +114 -550
- 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 -192
- package/src/Transactions.js +0 -145
- package/src/engine/StorageEngine.js +0 -250
- package/src/engine/storage/FileStorageEngine.js +0 -213
- package/src/type/index.js +0 -32
- /package/src/{type/resolved → data/properties}/SlugType.js +0 -0
@@ -0,0 +1,631 @@
|
|
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
|
+
* Represents a transactional operation to be executed, typically queued and later committed.
|
12
|
+
*
|
13
|
+
* Stores the method to invoke, the arguments to apply, and tracks the result or error state
|
14
|
+
* of the transaction once it's processed.
|
15
|
+
*
|
16
|
+
* @class Transaction
|
17
|
+
*/
|
18
|
+
export class Transaction {
|
19
|
+
constructor(method, ...args) {
|
20
|
+
this.method = method;
|
21
|
+
this.args = args;
|
22
|
+
this.original = undefined;
|
23
|
+
this.error = undefined;
|
24
|
+
this.committed = false;
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
/**
|
29
|
+
* @class Connection
|
30
|
+
*/
|
31
|
+
export default class Connection {
|
32
|
+
/**
|
33
|
+
* @private
|
34
|
+
* @property {StorageEngine}
|
35
|
+
*/
|
36
|
+
#storage;
|
37
|
+
|
38
|
+
/**
|
39
|
+
* @private
|
40
|
+
* @property {CacheEngine|undefined}
|
41
|
+
*/
|
42
|
+
#cache;
|
43
|
+
|
44
|
+
/**
|
45
|
+
* @private
|
46
|
+
* @property {Record<String, Model.constructor>}
|
47
|
+
*/
|
48
|
+
#models;
|
49
|
+
|
50
|
+
/**
|
51
|
+
* Create a new connection
|
52
|
+
* @param {StorageEngine} storage
|
53
|
+
* @param {CacheEngine|undefined} cache
|
54
|
+
* @param {Array<Model.constructor>} models
|
55
|
+
*/
|
56
|
+
constructor(storage, cache, models) {
|
57
|
+
this.#storage = storage;
|
58
|
+
this.#cache = cache;
|
59
|
+
this.#models = Object.fromEntries((models ?? []).map(model => [model.name, model]));
|
60
|
+
|
61
|
+
if (!this.#storage) throw new MissingArgumentsConnectionError('No storage engine provided');
|
62
|
+
}
|
63
|
+
|
64
|
+
/**
|
65
|
+
* Get a model by its id
|
66
|
+
* @param {String} modelId
|
67
|
+
* @throws {ModelNotRegisteredConnectionError}
|
68
|
+
* @return {Promise<Model>}
|
69
|
+
*/
|
70
|
+
async get(modelId) {
|
71
|
+
const constructor = this.#getModelConstructorFromId(modelId);
|
72
|
+
|
73
|
+
const data = await this.#storage.getModel(modelId);
|
74
|
+
|
75
|
+
return constructor.fromData(data);
|
76
|
+
}
|
77
|
+
|
78
|
+
/**
|
79
|
+
* Accepts a dry model, for example:
|
80
|
+
*
|
81
|
+
* - an object with only an id property
|
82
|
+
* - a model missing linked model fields
|
83
|
+
* - a model missing non-indexed properties
|
84
|
+
*
|
85
|
+
* and fetches all data for a model.
|
86
|
+
* @param {Object} dryModel
|
87
|
+
* @return {Promise<Model>}
|
88
|
+
*/
|
89
|
+
async hydrate(dryModel) {
|
90
|
+
const hydratedModels = {};
|
91
|
+
|
92
|
+
/**
|
93
|
+
* Recursively hydrates a single model and its nested properties.
|
94
|
+
*
|
95
|
+
* @param {Object|Model} modelToProcess - The model instance to hydrate.
|
96
|
+
* @returns {Promise<Model>} The hydrated model instance.
|
97
|
+
*/
|
98
|
+
const hydrateModel = async (modelToProcess) => {
|
99
|
+
hydratedModels[modelToProcess.id] = modelToProcess;
|
100
|
+
|
101
|
+
for (const [name, property] of Object.entries(modelToProcess)) {
|
102
|
+
if (Model.isDryModel(property)) {
|
103
|
+
// skipcq: JS-0129
|
104
|
+
modelToProcess[name] = await hydrateSubModel(property);
|
105
|
+
} else if (Array.isArray(property) && Model.isDryModel(property[0])) {
|
106
|
+
// skipcq: JS-0129
|
107
|
+
modelToProcess[name] = await hydrateModelList(property);
|
108
|
+
}
|
109
|
+
}
|
110
|
+
|
111
|
+
return modelToProcess;
|
112
|
+
};
|
113
|
+
|
114
|
+
/**
|
115
|
+
* Hydrates a nested sub-model if it hasn't already been hydrated.
|
116
|
+
*
|
117
|
+
* @param {Object} property - The sub-model with a known ID but incomplete data.
|
118
|
+
* @returns {Promise<Model>} The fully hydrated sub-model.
|
119
|
+
*/
|
120
|
+
const hydrateSubModel = async (property) => {
|
121
|
+
if (hydratedModels[property.id]) {
|
122
|
+
return hydratedModels[property.id];
|
123
|
+
}
|
124
|
+
|
125
|
+
const subModel = await this.get(property.id);
|
126
|
+
|
127
|
+
const hydratedSubModel = await hydrateModel(subModel);
|
128
|
+
hydratedModels[property.id] = hydratedSubModel;
|
129
|
+
return hydratedSubModel;
|
130
|
+
};
|
131
|
+
|
132
|
+
/**
|
133
|
+
* Hydrates a list of related sub-models.
|
134
|
+
*
|
135
|
+
* @param {Array<Object>} property - Array of dry sub-models.
|
136
|
+
* @returns {Promise<Array<Model>>} Array of hydrated sub-models.
|
137
|
+
*/
|
138
|
+
const hydrateModelList = async (property) => {
|
139
|
+
const newModelList = await Promise.all(property.map(subModel => {
|
140
|
+
if (hydratedModels[subModel.id]) {
|
141
|
+
return hydratedModels[subModel.id];
|
142
|
+
}
|
143
|
+
|
144
|
+
return this.get(subModel.id);
|
145
|
+
}));
|
146
|
+
|
147
|
+
return Promise.all(newModelList.map(async subModel => {
|
148
|
+
if (hydratedModels[subModel.id]) {
|
149
|
+
return hydratedModels[subModel.id];
|
150
|
+
}
|
151
|
+
|
152
|
+
const hydratedSubModel = await hydrateModel(subModel);
|
153
|
+
hydratedModels[hydratedSubModel.id] = hydratedSubModel;
|
154
|
+
return hydratedSubModel;
|
155
|
+
}));
|
156
|
+
};
|
157
|
+
|
158
|
+
return hydrateModel(await this.get(dryModel.id));
|
159
|
+
}
|
160
|
+
|
161
|
+
/**
|
162
|
+
* Persists a model if it has changed, and updates all related models and their indexes
|
163
|
+
* @param {Model} model
|
164
|
+
* @return {Promise<void>}
|
165
|
+
*/
|
166
|
+
async put(model) {
|
167
|
+
const processedModels = [];
|
168
|
+
const modelsToPut = [];
|
169
|
+
const modelsToReindex = {};
|
170
|
+
const modelsToReindexSearch = {};
|
171
|
+
|
172
|
+
/**
|
173
|
+
* @param {Model} modelToProcess
|
174
|
+
* @return {Promise<void>}
|
175
|
+
*/
|
176
|
+
const processModel = async (modelToProcess) => {
|
177
|
+
if (processedModels.includes(modelToProcess.id))
|
178
|
+
return;
|
179
|
+
|
180
|
+
processedModels.push(modelToProcess.id);
|
181
|
+
|
182
|
+
if (!Object.keys(this.#models).includes(modelToProcess.constructor.name))
|
183
|
+
throw new ModelNotRegisteredConnectionError(modelToProcess, this.#storage);
|
184
|
+
|
185
|
+
modelToProcess.validate();
|
186
|
+
const currentModel = await this.get(modelToProcess.id).catch(() => null);
|
187
|
+
|
188
|
+
const modelToProcessHasChanged = !_.isEqual(currentModel?.toData() || {}, modelToProcess.toData());
|
189
|
+
|
190
|
+
if (modelToProcessHasChanged) modelsToPut.push(modelToProcess);
|
191
|
+
|
192
|
+
if (
|
193
|
+
Boolean(modelToProcess.constructor.indexedProperties().length) &&
|
194
|
+
(!currentModel || !_.isEqual(currentModel.toIndexData(), modelToProcess.toIndexData()))
|
195
|
+
) {
|
196
|
+
const modelToProcessConstructor = this.#getModelConstructorFromId(modelToProcess.id);
|
197
|
+
modelsToReindex[modelToProcessConstructor] = modelsToReindex[modelToProcessConstructor] || [];
|
198
|
+
modelsToReindex[modelToProcessConstructor].push(modelToProcess);
|
199
|
+
}
|
200
|
+
|
201
|
+
if (
|
202
|
+
Boolean(modelToProcess.constructor.searchProperties().length) &&
|
203
|
+
(!currentModel || !_.isEqual(currentModel.toSearchData(), modelToProcess.toSearchData()))
|
204
|
+
) {
|
205
|
+
const modelToProcessConstructor = this.#getModelConstructorFromId(modelToProcess.id);
|
206
|
+
modelsToReindexSearch[modelToProcessConstructor] = modelsToReindexSearch[modelToProcessConstructor] || [];
|
207
|
+
modelsToReindexSearch[modelToProcessConstructor].push(modelToProcess);
|
208
|
+
}
|
209
|
+
|
210
|
+
for (const [field, value] of Object.entries(modelToProcess)) {
|
211
|
+
if (Model.isModel(value)) {
|
212
|
+
await processModel(modelToProcess[field]);
|
213
|
+
}
|
214
|
+
}
|
215
|
+
};
|
216
|
+
|
217
|
+
await processModel(model);
|
218
|
+
|
219
|
+
await Promise.all([
|
220
|
+
Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
|
221
|
+
Promise.all(Object.entries(modelsToReindex).map(async ([constructorName, models]) => {
|
222
|
+
const modelConstructor = this.#models[constructorName];
|
223
|
+
const index = await this.#storage.getIndex(modelConstructor);
|
224
|
+
|
225
|
+
await this.#storage.putIndex(modelConstructor, {
|
226
|
+
...index || {},
|
227
|
+
...Object.fromEntries(models.map(m => [m.id, m.toIndexData()])),
|
228
|
+
});
|
229
|
+
})),
|
230
|
+
Promise.all(Object.entries(modelsToReindexSearch).map(async ([constructorName, models]) => {
|
231
|
+
const modelConstructor = this.#models[constructorName];
|
232
|
+
const index = await this.#storage.getSearchIndex(modelConstructor);
|
233
|
+
|
234
|
+
await this.#storage.putSearchIndex(modelConstructor, {
|
235
|
+
...index || {},
|
236
|
+
...Object.fromEntries(models.map(m => [m.id, m.toSearchData()])),
|
237
|
+
});
|
238
|
+
})),
|
239
|
+
]);
|
240
|
+
}
|
241
|
+
|
242
|
+
/**
|
243
|
+
* Delete a model and update indexes that reference it
|
244
|
+
* @param {Model} model
|
245
|
+
* @param {Array<string>} propagateTo - List of model ids that are expected to be deleted
|
246
|
+
* @throws {ModelNotRegisteredConnectionError}
|
247
|
+
* @throws {ModelNotFoundStorageEngineError}
|
248
|
+
*/
|
249
|
+
async delete(model, propagateTo = []) {
|
250
|
+
const processedModels = [];
|
251
|
+
const modelsToDelete = [];
|
252
|
+
const modelsToPut = [];
|
253
|
+
const indexCache = {};
|
254
|
+
const indexActions = {};
|
255
|
+
const searchIndexCache = {};
|
256
|
+
const searchIndexActions = {};
|
257
|
+
const modelCache = {};
|
258
|
+
|
259
|
+
propagateTo.push(model.id);
|
260
|
+
|
261
|
+
/**
|
262
|
+
* Process a model for deletion
|
263
|
+
* @param {Model} modelToProcess
|
264
|
+
* @return {Promise<void>}
|
265
|
+
*/
|
266
|
+
const processModel = async (modelToProcess) => {
|
267
|
+
if (processedModels.includes(modelToProcess.id)) return;
|
268
|
+
|
269
|
+
processedModels.push(modelToProcess.id);
|
270
|
+
|
271
|
+
const modelsToProcess = [];
|
272
|
+
if (!Object.keys(this.#models).includes(modelToProcess.constructor.name))
|
273
|
+
throw new ModelNotRegisteredConnectionError(modelToProcess, this.#storage);
|
274
|
+
|
275
|
+
const currentModel = modelCache[modelToProcess.id] ?? await this.get(modelToProcess.id);
|
276
|
+
modelCache[currentModel.id] = currentModel;
|
277
|
+
|
278
|
+
if (!modelsToDelete.includes(currentModel.id)) modelsToDelete.push(currentModel.id);
|
279
|
+
|
280
|
+
const modelToProcessConstructor = this.#getModelConstructorFromId(modelToProcess.id);
|
281
|
+
indexActions[modelToProcessConstructor] = indexActions[modelToProcessConstructor] ?? [];
|
282
|
+
searchIndexActions[modelToProcessConstructor] = searchIndexActions[modelToProcessConstructor] ?? [];
|
283
|
+
|
284
|
+
if (currentModel.constructor.indexedPropertiesResolved().length) {
|
285
|
+
indexActions[modelToProcessConstructor].push(['delete', modelToProcess]);
|
286
|
+
}
|
287
|
+
|
288
|
+
if (currentModel.constructor.searchProperties().length) {
|
289
|
+
searchIndexActions[modelToProcessConstructor].push(['delete', modelToProcess]);
|
290
|
+
}
|
291
|
+
|
292
|
+
const linkedModels = await this.#getInstancesLinkedTo(modelToProcess, indexCache);
|
293
|
+
const links = this.#getLinksFor(modelToProcess.constructor);
|
294
|
+
Object.values(Object.fromEntries(await Promise.all(
|
295
|
+
Object.entries(linkedModels)
|
296
|
+
.map(async ([constructor, updatableModels]) => [
|
297
|
+
constructor,
|
298
|
+
await Promise.all(updatableModels.map(async m => {
|
299
|
+
const upToDateModel = modelCache[m.id] ?? await this.get(m.id);
|
300
|
+
modelCache[upToDateModel.id] = upToDateModel;
|
301
|
+
return upToDateModel;
|
302
|
+
})),
|
303
|
+
]),
|
304
|
+
))).flat(1)
|
305
|
+
.forEach(m =>
|
306
|
+
Object.entries(links[m.constructor.name])
|
307
|
+
.forEach(([linkName, modelConstructor]) => {
|
308
|
+
if ((
|
309
|
+
typeof modelConstructor[linkName] === 'function' &&
|
310
|
+
!/^class/.test(Function.prototype.toString.call(modelConstructor[linkName])) &&
|
311
|
+
!Model.isModel(modelConstructor[linkName]) ?
|
312
|
+
modelConstructor[linkName]() : modelConstructor
|
313
|
+
)._required) {
|
314
|
+
if (!modelsToDelete.includes(m.id)) modelsToDelete.push(m.id);
|
315
|
+
modelsToProcess.push(m);
|
316
|
+
} else {
|
317
|
+
m[linkName] = undefined;
|
318
|
+
modelsToPut.push(m);
|
319
|
+
|
320
|
+
indexActions[this.#getModelConstructorFromId(m.id)].push(['reindex', m]);
|
321
|
+
|
322
|
+
if (m.constructor.searchProperties().length) {
|
323
|
+
searchIndexActions[this.#getModelConstructorFromId(m.id)].push(['reindex', m]);
|
324
|
+
}
|
325
|
+
}
|
326
|
+
}),
|
327
|
+
);
|
328
|
+
|
329
|
+
for (const modelToBeProcessed of modelsToProcess) {
|
330
|
+
await processModel(modelToBeProcessed);
|
331
|
+
}
|
332
|
+
};
|
333
|
+
|
334
|
+
await processModel(model);
|
335
|
+
|
336
|
+
const unrequestedDeletions = modelsToDelete.filter(m => !propagateTo.includes(m));
|
337
|
+
if (unrequestedDeletions.length) {
|
338
|
+
throw new DeleteHasUnintendedConsequencesStorageEngineError(model.id, {
|
339
|
+
willDelete: unrequestedDeletions,
|
340
|
+
});
|
341
|
+
}
|
342
|
+
|
343
|
+
await Promise.all([
|
344
|
+
Promise.all(Object.entries(indexActions).map(async ([constructorName, actions]) => {
|
345
|
+
const modelConstructor = this.#models[constructorName];
|
346
|
+
indexCache[constructorName] = indexCache[constructorName] ?? await this.#storage.getIndex(modelConstructor);
|
347
|
+
|
348
|
+
actions.forEach(([action, actionModel]) => {
|
349
|
+
if (action === 'delete') {
|
350
|
+
indexCache[constructorName] = _.omit(indexCache[constructorName], [actionModel.id]);
|
351
|
+
}
|
352
|
+
if (action === 'reindex') {
|
353
|
+
indexCache[constructorName] = {
|
354
|
+
...indexCache[constructorName],
|
355
|
+
[actionModel.id]: actionModel.toIndexData(),
|
356
|
+
};
|
357
|
+
}
|
358
|
+
});
|
359
|
+
})),
|
360
|
+
Promise.all(Object.entries(searchIndexActions).map(async ([constructorName, actions]) => {
|
361
|
+
const modelConstructor = this.#models[constructorName];
|
362
|
+
searchIndexCache[constructorName] = searchIndexCache[constructorName] ?? await this.#storage.getSearchIndex(modelConstructor);
|
363
|
+
|
364
|
+
actions.forEach(([action, actionModel]) => {
|
365
|
+
if (action === 'delete') {
|
366
|
+
searchIndexCache[constructorName] = _.omit(searchIndexCache[constructorName], [actionModel.id]);
|
367
|
+
}
|
368
|
+
if (action === 'reindex') {
|
369
|
+
searchIndexCache[constructorName] = {
|
370
|
+
...searchIndexCache[constructorName],
|
371
|
+
[actionModel.id]: actionModel.toSearchData(),
|
372
|
+
};
|
373
|
+
}
|
374
|
+
});
|
375
|
+
})),
|
376
|
+
]);
|
377
|
+
|
378
|
+
await Promise.all([
|
379
|
+
Promise.all(modelsToDelete.map(m => this.#storage.deleteModel(m))),
|
380
|
+
Promise.all(modelsToPut.map(m => this.#storage.putModel(m.toData()))),
|
381
|
+
Promise.all(
|
382
|
+
Object.entries(indexCache)
|
383
|
+
.map(([constructorName, index]) => this.#storage.putIndex(this.#models[constructorName], index)),
|
384
|
+
),
|
385
|
+
Promise.all(
|
386
|
+
Object.entries(searchIndexCache)
|
387
|
+
.map(([constructorName, index]) =>
|
388
|
+
this.#models[constructorName].searchProperties().length > 0 ?
|
389
|
+
this.#storage.putSearchIndex(this.#models[constructorName], index) :
|
390
|
+
Promise.resolve(),
|
391
|
+
),
|
392
|
+
),
|
393
|
+
]);
|
394
|
+
}
|
395
|
+
|
396
|
+
/**
|
397
|
+
* Search the given model's search properties for matching results.
|
398
|
+
* Wildcards: character '*' can be placed at any location in a query
|
399
|
+
* Fields: search for a specific field's value with 'field:value'
|
400
|
+
* Boosting: if foo is important try 'foo^10 bar'
|
401
|
+
* Fuzzy: 'foo~1' will match 'boo' but not 'bao', 'foo~2' would match 'bao'
|
402
|
+
* Must include: '+foo bar' must include 'foo' and may include 'bar'
|
403
|
+
* Must not include: '-foo bar' must not include 'foo' and may include 'bar'
|
404
|
+
* Mixed include: '+foo -bar' must include 'foo' must not include 'bar'
|
405
|
+
* @param {Model.constructor} constructor
|
406
|
+
* @param {string} query
|
407
|
+
* @return {Promise<Array<SearchResult>>}
|
408
|
+
*/
|
409
|
+
async search(constructor, query) {
|
410
|
+
const searchIndex = await this.#storage.getSearchIndex(constructor)
|
411
|
+
.then(index => new SearchIndex(constructor, index));
|
412
|
+
|
413
|
+
return searchIndex.search(query);
|
414
|
+
}
|
415
|
+
|
416
|
+
/**
|
417
|
+
* Find using a structured query and indexed fields.
|
418
|
+
*
|
419
|
+
* @param {Model.constructor} constructor
|
420
|
+
* @param {Object} query
|
421
|
+
* @return {Promise<Array<SearchResult>>}
|
422
|
+
*/
|
423
|
+
async find(constructor, query) {
|
424
|
+
const findIndex = await this.#storage.getIndex(constructor)
|
425
|
+
.then(index => new FindIndex(constructor, index));
|
426
|
+
|
427
|
+
return findIndex.query(query);
|
428
|
+
}
|
429
|
+
|
430
|
+
/**
|
431
|
+
* Start a transaction, allowing multiple queries to be queued up and committed in one go.
|
432
|
+
* Should an error occur, any committed operations will be reverted.
|
433
|
+
* @return {Connection}
|
434
|
+
*/
|
435
|
+
transaction() {
|
436
|
+
const transactions = [];
|
437
|
+
|
438
|
+
const engine = CreateTransactionalStorageEngine(transactions, this.#storage);
|
439
|
+
|
440
|
+
const connection = new this.constructor(engine, this.#cache, Object.values(this.#models));
|
441
|
+
|
442
|
+
connection.commit = async () => {
|
443
|
+
try {
|
444
|
+
for (const [index, transaction] of transactions.entries()) {
|
445
|
+
try {
|
446
|
+
if (transaction.method === 'putModel')
|
447
|
+
transactions[index].original = await this.#storage.getModel(transaction.args[0].id).catch(() => undefined);
|
448
|
+
|
449
|
+
if (transaction.method === 'deleteModel')
|
450
|
+
transactions[index].original = await this.#storage.getModel(transaction.args[0]);
|
451
|
+
|
452
|
+
await this.#storage[transaction.method](...transaction.args);
|
453
|
+
|
454
|
+
transactions[index].committed = true;
|
455
|
+
} catch (error) {
|
456
|
+
transactions[index].error = error;
|
457
|
+
throw error;
|
458
|
+
}
|
459
|
+
}
|
460
|
+
} catch (error) {
|
461
|
+
await Promise.all(
|
462
|
+
transactions
|
463
|
+
.filter(t => t.committed && t.original)
|
464
|
+
.map(t => this.#storage.putModel(t.original)),
|
465
|
+
);
|
466
|
+
|
467
|
+
throw new CommitFailedTransactionError(transactions, error);
|
468
|
+
}
|
469
|
+
};
|
470
|
+
|
471
|
+
return connection;
|
472
|
+
}
|
473
|
+
|
474
|
+
/**
|
475
|
+
* Get the model constructor from a model id
|
476
|
+
* @param {String} modelId
|
477
|
+
* @throws ModelNotRegisteredConnectionError
|
478
|
+
* @return Model.constructor
|
479
|
+
*/
|
480
|
+
#getModelConstructorFromId(modelId) {
|
481
|
+
const modelName = modelId.split('/')[0];
|
482
|
+
const constructor = this.#models[modelName];
|
483
|
+
|
484
|
+
if (!constructor) throw new ModelNotRegisteredConnectionError(modelName, this.#storage);
|
485
|
+
|
486
|
+
return constructor;
|
487
|
+
}
|
488
|
+
|
489
|
+
/**
|
490
|
+
* Get model classes that are directly linked to the given model in either direction
|
491
|
+
* @param {Model.constructor} model
|
492
|
+
* @return {Record<string, Record<string, Model.constructor>>}
|
493
|
+
*/
|
494
|
+
#getLinksFor(model) {
|
495
|
+
return Object.fromEntries(
|
496
|
+
Object.entries(this.#getAllModelLinks())
|
497
|
+
.filter(([modelName, links]) =>
|
498
|
+
model.name === modelName ||
|
499
|
+
Object.values(links).some((link) => link.name === model.name),
|
500
|
+
),
|
501
|
+
);
|
502
|
+
}
|
503
|
+
|
504
|
+
/**
|
505
|
+
* Get all model links
|
506
|
+
* @return {Record<string, Record<string, Model.constructor>>}
|
507
|
+
*/
|
508
|
+
#getAllModelLinks() {
|
509
|
+
return Object.entries(this.#models)
|
510
|
+
.map(([registeredModelName, registeredModelClass]) =>
|
511
|
+
Object.entries(registeredModelClass)
|
512
|
+
.map(([propertyName, propertyProperty]) => [
|
513
|
+
registeredModelName,
|
514
|
+
propertyName,
|
515
|
+
typeof propertyProperty === 'function' &&
|
516
|
+
!/^class/.test(Function.prototype.toString.call(propertyProperty)) &&
|
517
|
+
!Model.isModel(propertyProperty) ?
|
518
|
+
propertyProperty() : propertyProperty,
|
519
|
+
])
|
520
|
+
.filter(([_m, _p, type]) => Model.isModel(type))
|
521
|
+
.map(([containingModel, propertyName, propertyProperty]) => ({
|
522
|
+
containingModel,
|
523
|
+
propertyName,
|
524
|
+
propertyProperty,
|
525
|
+
})),
|
526
|
+
)
|
527
|
+
.flat()
|
528
|
+
.reduce((accumulator, {containingModel, propertyName, propertyProperty}) => ({
|
529
|
+
...accumulator,
|
530
|
+
[containingModel]: {
|
531
|
+
...accumulator[containingModel] || {},
|
532
|
+
[propertyName]: propertyProperty,
|
533
|
+
},
|
534
|
+
}), {});
|
535
|
+
}
|
536
|
+
|
537
|
+
/**
|
538
|
+
* Get model instance that are directly linked to the given model in either direction
|
539
|
+
* @param {Model} model
|
540
|
+
* @param {object} cache
|
541
|
+
* @return {Record<string, Record<string, Model>>}
|
542
|
+
*/
|
543
|
+
async #getInstancesLinkedTo(model, cache) {
|
544
|
+
return Object.fromEntries(
|
545
|
+
Object.entries(
|
546
|
+
await Promise.all(
|
547
|
+
Object.entries(this.#getLinksFor(model.constructor))
|
548
|
+
.map(([name, _index]) =>
|
549
|
+
cache[name] ? Promise.resolve([name, Object.values(cache[name])]) :
|
550
|
+
this.#storage.getIndex(this.#models[name])
|
551
|
+
.then(i => {
|
552
|
+
cache[name] = i;
|
553
|
+
return [name, Object.values(i)];
|
554
|
+
}),
|
555
|
+
),
|
556
|
+
).then(Object.fromEntries),
|
557
|
+
).map(([name, index]) => [
|
558
|
+
name,
|
559
|
+
index.map(item => Object.fromEntries(
|
560
|
+
Object.entries(item)
|
561
|
+
.filter(([propertyName, property]) => propertyName === 'id' || property?.id === model.id),
|
562
|
+
)).filter(item => Object.keys(item).length > 1),
|
563
|
+
]),
|
564
|
+
);
|
565
|
+
}
|
566
|
+
}
|
567
|
+
|
568
|
+
/**
|
569
|
+
* Base class for errors that occur during connection operations.
|
570
|
+
*
|
571
|
+
* @class ConnectionError
|
572
|
+
* @extends Error
|
573
|
+
*/
|
574
|
+
export class ConnectionError extends Error {
|
575
|
+
}
|
576
|
+
|
577
|
+
/**
|
578
|
+
* Thrown when a connection is created with missing arguments.
|
579
|
+
*
|
580
|
+
* @class MissingArgumentsConnectionError
|
581
|
+
* @extends ConnectionError
|
582
|
+
*/
|
583
|
+
export class MissingArgumentsConnectionError extends ConnectionError {
|
584
|
+
}
|
585
|
+
|
586
|
+
/**
|
587
|
+
* Thrown when a model class is not registered.
|
588
|
+
*
|
589
|
+
* @class ModelNotRegisteredConnectionError
|
590
|
+
* @extends ConnectionError
|
591
|
+
*/
|
592
|
+
export class ModelNotRegisteredConnectionError extends ConnectionError {
|
593
|
+
/**
|
594
|
+
* @param {Model|String} constructor
|
595
|
+
* @param {Connection} connection
|
596
|
+
*/
|
597
|
+
constructor(constructor, connection) {
|
598
|
+
const modelName = typeof constructor === 'string' ? constructor : constructor.constructor.name;
|
599
|
+
super(`The model ${modelName} is not registered in the storage engine ${connection.constructor.name}`);
|
600
|
+
}
|
601
|
+
}
|
602
|
+
|
603
|
+
/**
|
604
|
+
* Base class for errors that occur during transactions.
|
605
|
+
*
|
606
|
+
* @class TransactionError
|
607
|
+
* @extends {Error}
|
608
|
+
*/
|
609
|
+
class TransactionError extends Error {
|
610
|
+
}
|
611
|
+
|
612
|
+
/**
|
613
|
+
* Thrown when a transaction fails to commit.
|
614
|
+
*
|
615
|
+
* Contains the original error and the list of transactions involved.
|
616
|
+
*
|
617
|
+
* @class CommitFailedTransactionError
|
618
|
+
* @extends {TransactionError}
|
619
|
+
*/
|
620
|
+
export class CommitFailedTransactionError extends TransactionError {
|
621
|
+
/**
|
622
|
+
*
|
623
|
+
* @param {Array<Transaction>} transactions
|
624
|
+
* @param {Error} error
|
625
|
+
*/
|
626
|
+
constructor(transactions, error) {
|
627
|
+
super('Transaction failed to commit.');
|
628
|
+
this.transactions = transactions;
|
629
|
+
this.error = error;
|
630
|
+
}
|
631
|
+
}
|