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