@acodeninja/persist 3.0.0-next.1 → 3.0.0-next.10
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/jest.config.cjs +26 -0
- package/package.json +7 -10
- package/src/Query.js +2 -2
- package/src/SchemaCompiler.js +6 -2
- package/src/engine/StorageEngine.js +517 -0
- package/src/engine/storage/FileStorageEngine.js +7 -0
- package/src/engine/storage/HTTPStorageEngine.js +6 -0
- package/src/engine/storage/S3StorageEngine.js +7 -0
- package/src/engine/storage/StorageEngine.js +35 -0
- package/src/type/Model.js +35 -2
- package/src/type/simple/BooleanType.js +4 -4
- package/src/type/simple/DateType.js +4 -4
- package/src/type/simple/NumberType.js +4 -4
- package/src/type/simple/StringType.js +4 -4
- package/src/type/simple/SimpleType.js +0 -14
package/jest.config.cjs
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
/** @type {import('jest').Config} */
|
2
|
+
const config = {
|
3
|
+
coveragePathIgnorePatterns: [
|
4
|
+
'node_modules',
|
5
|
+
'test/fixtures',
|
6
|
+
'test/mocks',
|
7
|
+
'test/scripts',
|
8
|
+
],
|
9
|
+
coverageThreshold: {
|
10
|
+
global: {
|
11
|
+
branches: 100,
|
12
|
+
functions: 100,
|
13
|
+
lines: 100,
|
14
|
+
statements: 100,
|
15
|
+
},
|
16
|
+
},
|
17
|
+
testMatch: [
|
18
|
+
'**/*.test.js',
|
19
|
+
],
|
20
|
+
watchPathIgnorePatterns: [
|
21
|
+
'coverage/',
|
22
|
+
'test/fixtures/minified',
|
23
|
+
],
|
24
|
+
};
|
25
|
+
|
26
|
+
module.exports = config;
|
package/package.json
CHANGED
@@ -1,13 +1,12 @@
|
|
1
1
|
{
|
2
2
|
"name": "@acodeninja/persist",
|
3
|
-
"version": "3.0.0-next.
|
3
|
+
"version": "3.0.0-next.10",
|
4
4
|
"description": "A JSON based data modelling and persistence module with alternate storage mechanisms.",
|
5
5
|
"type": "module",
|
6
6
|
"scripts": {
|
7
|
-
"test": "
|
8
|
-
"test:watch": "
|
9
|
-
"test:coverage": "
|
10
|
-
"test:coverage:report": "c8 --experimental-monocart --100 --lcov --reporter=console-details --reporter=v8 ava",
|
7
|
+
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest",
|
8
|
+
"test:watch": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest --watch",
|
9
|
+
"test:coverage": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest --collect-coverage",
|
11
10
|
"lint": "eslint ./",
|
12
11
|
"prepare": "husky"
|
13
12
|
},
|
@@ -38,14 +37,12 @@
|
|
38
37
|
"@commitlint/cli": "^19.6.1",
|
39
38
|
"@commitlint/config-conventional": "^19.6.0",
|
40
39
|
"@eslint/js": "^9.19.0",
|
40
|
+
"@jest/globals": "^29.7.0",
|
41
41
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
42
|
-
"ava": "^6.2.0",
|
43
|
-
"c8": "^10.1.3",
|
44
42
|
"eslint": "^9.19.0",
|
45
43
|
"globals": "^15.14.0",
|
46
44
|
"husky": "^9.1.7",
|
47
|
-
"
|
48
|
-
"semantic-release": "^24.2.1"
|
49
|
-
"sinon": "^19.0.2"
|
45
|
+
"jest": "^29.7.0",
|
46
|
+
"semantic-release": "^24.2.1"
|
50
47
|
}
|
51
48
|
}
|
package/src/Query.js
CHANGED
@@ -69,10 +69,10 @@ class Query {
|
|
69
69
|
*
|
70
70
|
* @private
|
71
71
|
* @param {*} subject - The subject to be matched.
|
72
|
-
* @param {Object}
|
72
|
+
* @param {Object} inputQuery - The query to match against.
|
73
73
|
* @returns {boolean} True if the subject matches the query, otherwise false.
|
74
74
|
*/
|
75
|
-
_matchesQuery(subject, inputQuery
|
75
|
+
_matchesQuery(subject, inputQuery) {
|
76
76
|
if (['string', 'number', 'boolean'].includes(typeof inputQuery)) return subject === inputQuery;
|
77
77
|
|
78
78
|
if (inputQuery?.$is !== undefined && subject === inputQuery.$is) return true;
|
package/src/SchemaCompiler.js
CHANGED
@@ -79,7 +79,11 @@ class SchemaCompiler {
|
|
79
79
|
schema.properties[name].items.format = property?._items._format;
|
80
80
|
}
|
81
81
|
|
82
|
-
|
82
|
+
const prop = typeof property?._items === 'function' &&
|
83
|
+
!/^class/.test(Function.prototype.toString.call(property?._items)) ?
|
84
|
+
property?._items() : property?._items;
|
85
|
+
|
86
|
+
if (Type.Model.isModel(prop)) {
|
83
87
|
schema.properties[name].items = {
|
84
88
|
type: 'object',
|
85
89
|
additionalProperties: false,
|
@@ -87,7 +91,7 @@ class SchemaCompiler {
|
|
87
91
|
properties: {
|
88
92
|
id: {
|
89
93
|
type: 'string',
|
90
|
-
pattern: `^${
|
94
|
+
pattern: `^${prop.toString()}/[A-Z0-9]+$`,
|
91
95
|
},
|
92
96
|
},
|
93
97
|
};
|
@@ -0,0 +1,517 @@
|
|
1
|
+
import Type from '../type/index.js';
|
2
|
+
import _ from 'lodash';
|
3
|
+
|
4
|
+
export default class StorageEngine {
|
5
|
+
/**
|
6
|
+
* @param {Object} configuration
|
7
|
+
* @param {Array<Type.Model.constructor>?} models
|
8
|
+
*/
|
9
|
+
constructor(configuration = {}, models = null) {
|
10
|
+
this.configuration = configuration;
|
11
|
+
this.models = Object.fromEntries((models ?? []).map(model => [model.name, model]));
|
12
|
+
}
|
13
|
+
|
14
|
+
/**
|
15
|
+
* Persists a model if it has changed, and updates all related models and their indexes
|
16
|
+
* @param {Type.Model} model
|
17
|
+
* @return {Promise<void>}
|
18
|
+
*/
|
19
|
+
async put(model) {
|
20
|
+
const processedModels = [];
|
21
|
+
const modelsToPut = [];
|
22
|
+
const modelsToReindex = {};
|
23
|
+
const modelsToReindexSearch = {};
|
24
|
+
|
25
|
+
/**
|
26
|
+
* @param {Type.Model} modelToProcess
|
27
|
+
* @return {Promise<void>}
|
28
|
+
*/
|
29
|
+
const processModel = async (modelToProcess) => {
|
30
|
+
if (processedModels.includes(modelToProcess.id))
|
31
|
+
return;
|
32
|
+
|
33
|
+
processedModels.push(modelToProcess.id);
|
34
|
+
|
35
|
+
if (!Object.keys(this.models).includes(modelToProcess.constructor.name))
|
36
|
+
throw new ModelNotRegisteredStorageEngineError(modelToProcess, this);
|
37
|
+
|
38
|
+
modelToProcess.validate();
|
39
|
+
const currentModel = await this.get(modelToProcess.id).catch(() => null);
|
40
|
+
|
41
|
+
const modelToProcessHasChanged = JSON.stringify(currentModel?.toData() || {}) !== JSON.stringify(modelToProcess.toData());
|
42
|
+
|
43
|
+
if (modelToProcessHasChanged) modelsToPut.push(modelToProcess);
|
44
|
+
|
45
|
+
if (
|
46
|
+
Boolean(modelToProcess.constructor.indexedProperties().length) &&
|
47
|
+
indexedFieldsHaveChanged(currentModel, modelToProcess)
|
48
|
+
) {
|
49
|
+
const modelToProcessConstructor = this.getModelConstructorFromId(modelToProcess.id);
|
50
|
+
modelsToReindex[modelToProcessConstructor] = modelsToReindex[modelToProcessConstructor] || [];
|
51
|
+
modelsToReindex[modelToProcessConstructor].push(modelToProcess);
|
52
|
+
}
|
53
|
+
|
54
|
+
if (
|
55
|
+
Boolean(modelToProcess.constructor.searchProperties().length) &&
|
56
|
+
searchableFieldsHaveChanged(currentModel, modelToProcess)
|
57
|
+
) {
|
58
|
+
const modelToProcessConstructor = this.getModelConstructorFromId(modelToProcess.id);
|
59
|
+
modelsToReindexSearch[modelToProcessConstructor] = modelsToReindexSearch[modelToProcessConstructor] || [];
|
60
|
+
modelsToReindexSearch[modelToProcessConstructor].push(modelToProcess);
|
61
|
+
}
|
62
|
+
|
63
|
+
for (const [field, value] of Object.entries(modelToProcess)) {
|
64
|
+
if (Type.Model.isModel(value)) {
|
65
|
+
await processModel(modelToProcess[field]);
|
66
|
+
}
|
67
|
+
}
|
68
|
+
};
|
69
|
+
|
70
|
+
await processModel(model);
|
71
|
+
|
72
|
+
await Promise.all([
|
73
|
+
Promise.all(modelsToPut.map(m => this._putModel(m.toData()))),
|
74
|
+
Promise.all(Object.entries(modelsToReindex).map(async ([constructorName, models]) => {
|
75
|
+
const modelConstructor = this.models[constructorName];
|
76
|
+
const index = await this._getIndex(modelConstructor);
|
77
|
+
|
78
|
+
await this._putIndex(modelConstructor, {
|
79
|
+
...index || {},
|
80
|
+
...Object.fromEntries(models.map(m => [m.id, m.toIndexData()])),
|
81
|
+
});
|
82
|
+
})),
|
83
|
+
Promise.all(Object.entries(modelsToReindexSearch).map(async ([constructorName, models]) => {
|
84
|
+
const modelConstructor = this.models[constructorName];
|
85
|
+
const index = await this._getSearchIndex(modelConstructor);
|
86
|
+
|
87
|
+
await this._putSearchIndex(modelConstructor, {
|
88
|
+
...index || {},
|
89
|
+
...Object.fromEntries(models.map(m => [m.id, m.toSearchData()])),
|
90
|
+
});
|
91
|
+
})),
|
92
|
+
]);
|
93
|
+
}
|
94
|
+
|
95
|
+
/**
|
96
|
+
* Get a model by its id
|
97
|
+
* @param {string} modelId
|
98
|
+
* @throws {ModelNotFoundStorageEngineError}
|
99
|
+
* @return {Promise<Type.Model>}
|
100
|
+
*/
|
101
|
+
get(modelId) {
|
102
|
+
try {
|
103
|
+
this.getModelConstructorFromId(modelId);
|
104
|
+
} catch (e) {
|
105
|
+
return Promise.reject(e);
|
106
|
+
}
|
107
|
+
return this._getModel(modelId);
|
108
|
+
}
|
109
|
+
|
110
|
+
/**
|
111
|
+
* Delete a model and update indexes that reference it
|
112
|
+
* @param {Type.Model} model
|
113
|
+
* @param {Array<string>} propagateTo - List of model ids that are expected to be deleted
|
114
|
+
* @throws {ModelNotRegisteredStorageEngineError}
|
115
|
+
* @throws {ModelNotFoundStorageEngineError}
|
116
|
+
*/
|
117
|
+
async delete(model, propagateTo = []) {
|
118
|
+
const processedModels = [];
|
119
|
+
const modelsToDelete = [];
|
120
|
+
const modelsToPut = [];
|
121
|
+
const indexCache = {};
|
122
|
+
const indexActions = {};
|
123
|
+
const searchIndexCache = {};
|
124
|
+
const searchIndexActions = {};
|
125
|
+
const modelCache = {};
|
126
|
+
|
127
|
+
propagateTo.push(model.id);
|
128
|
+
|
129
|
+
/**
|
130
|
+
* Process a model for deletion
|
131
|
+
* @param {Type.Model} modelToProcess
|
132
|
+
* @return {Promise<void>}
|
133
|
+
*/
|
134
|
+
const processModel = async (modelToProcess) => {
|
135
|
+
if (processedModels.includes(modelToProcess.id)) return;
|
136
|
+
processedModels.push(modelToProcess.id);
|
137
|
+
|
138
|
+
const modelsToProcess = [];
|
139
|
+
if (!Object.keys(this.models).includes(modelToProcess.constructor.name))
|
140
|
+
throw new ModelNotRegisteredStorageEngineError(modelToProcess, this);
|
141
|
+
|
142
|
+
const currentModel = modelCache[model.id] ?? await this.get(model.id);
|
143
|
+
modelCache[currentModel.id] = currentModel;
|
144
|
+
|
145
|
+
if (!modelsToDelete.includes(currentModel.id)) modelsToDelete.push(currentModel.id);
|
146
|
+
|
147
|
+
const modelToProcessConstructor = this.getModelConstructorFromId(modelToProcess.id);
|
148
|
+
indexActions[modelToProcessConstructor] = indexActions[modelToProcessConstructor] ?? [];
|
149
|
+
searchIndexActions[modelToProcessConstructor] = searchIndexActions[modelToProcessConstructor] ?? [];
|
150
|
+
|
151
|
+
if (currentModel.constructor.indexedPropertiesResolved().length) {
|
152
|
+
indexActions[modelToProcessConstructor].push(['delete', modelToProcess]);
|
153
|
+
}
|
154
|
+
|
155
|
+
if (currentModel.constructor.searchProperties().length) {
|
156
|
+
searchIndexActions[modelToProcessConstructor].push(['delete', modelToProcess]);
|
157
|
+
}
|
158
|
+
|
159
|
+
const linkedModels = await this.getInstancesLinkedTo(modelToProcess, indexCache);
|
160
|
+
const links = this.getLinksFor(modelToProcess.constructor);
|
161
|
+
Object.values(Object.fromEntries(await Promise.all(
|
162
|
+
Object.entries(linkedModels)
|
163
|
+
.map(async ([constructor, updatableModels]) => [
|
164
|
+
constructor,
|
165
|
+
await Promise.all(updatableModels.map(async m => {
|
166
|
+
const currentModel = modelCache[m.id] ?? await this.get(m.id);
|
167
|
+
modelCache[currentModel.id] = currentModel;
|
168
|
+
return currentModel;
|
169
|
+
})),
|
170
|
+
]),
|
171
|
+
))).flat(1)
|
172
|
+
.forEach(m =>
|
173
|
+
Object.entries(links[m.constructor.name])
|
174
|
+
.forEach(([linkName, modelConstructor]) => {
|
175
|
+
if ((
|
176
|
+
typeof modelConstructor[linkName] === 'function' &&
|
177
|
+
!/^class/.test(Function.prototype.toString.call(modelConstructor[linkName])) &&
|
178
|
+
!Type.Model.isModel(modelConstructor[linkName]) ?
|
179
|
+
modelConstructor[linkName]() : modelConstructor
|
180
|
+
)._required) {
|
181
|
+
if (!modelsToDelete.includes(m.id)) modelsToDelete.push(m.id);
|
182
|
+
modelsToProcess.push(m);
|
183
|
+
} else {
|
184
|
+
m[linkName] = undefined;
|
185
|
+
modelsToPut.push(m);
|
186
|
+
|
187
|
+
indexActions[this.getModelConstructorFromId(m.id)].push(['reindex', m]);
|
188
|
+
|
189
|
+
if (m.constructor.searchProperties().length) {
|
190
|
+
searchIndexActions[this.getModelConstructorFromId(m.id)].push(['reindex', m]);
|
191
|
+
}
|
192
|
+
}
|
193
|
+
}),
|
194
|
+
);
|
195
|
+
|
196
|
+
for (const model of modelsToProcess) {
|
197
|
+
await processModel(model);
|
198
|
+
}
|
199
|
+
};
|
200
|
+
|
201
|
+
await processModel(model);
|
202
|
+
|
203
|
+
const unrequestedDeletions = modelsToDelete.filter(m => !propagateTo.includes(m));
|
204
|
+
if (unrequestedDeletions.length) {
|
205
|
+
throw new DeleteHasUnintendedConsequencesStorageEngineError(model.id, {
|
206
|
+
willDelete: unrequestedDeletions,
|
207
|
+
});
|
208
|
+
}
|
209
|
+
|
210
|
+
await Promise.all([
|
211
|
+
Promise.all(Object.entries(indexActions).map(async ([constructorName, actions]) => {
|
212
|
+
const modelConstructor = this.models[constructorName];
|
213
|
+
indexCache[modelConstructor] = indexCache[modelConstructor] ?? await this._getIndex(modelConstructor);
|
214
|
+
|
215
|
+
actions.forEach(([action, actionModel]) => {
|
216
|
+
if (action === 'delete') {
|
217
|
+
indexCache[modelConstructor] = _.omit(indexCache[modelConstructor], [actionModel.id]);
|
218
|
+
}
|
219
|
+
if (action === 'reindex') {
|
220
|
+
indexCache[modelConstructor] = {
|
221
|
+
...indexCache[modelConstructor],
|
222
|
+
[actionModel.id]: actionModel.toIndexData(),
|
223
|
+
};
|
224
|
+
}
|
225
|
+
});
|
226
|
+
})),
|
227
|
+
Promise.all(Object.entries(searchIndexActions).map(async ([constructorName, actions]) => {
|
228
|
+
const modelConstructor = this.models[constructorName];
|
229
|
+
searchIndexCache[modelConstructor] = searchIndexCache[modelConstructor] ?? await this._getSearchIndex(modelConstructor);
|
230
|
+
|
231
|
+
actions.forEach(([action, actionModel]) => {
|
232
|
+
if (action === 'delete') {
|
233
|
+
searchIndexCache[modelConstructor] = _.omit(searchIndexCache[modelConstructor], [actionModel.id]);
|
234
|
+
}
|
235
|
+
if (action === 'reindex') {
|
236
|
+
searchIndexCache[modelConstructor] = {
|
237
|
+
...searchIndexCache[modelConstructor],
|
238
|
+
[actionModel.id]: actionModel.toSearchData(),
|
239
|
+
};
|
240
|
+
}
|
241
|
+
});
|
242
|
+
})),
|
243
|
+
]);
|
244
|
+
|
245
|
+
await Promise.all([
|
246
|
+
Promise.all(modelsToDelete.map(m => this._deleteModel(m))),
|
247
|
+
Promise.all(modelsToPut.map(m => this._putModel(m))),
|
248
|
+
Promise.all(
|
249
|
+
Object.entries(indexCache)
|
250
|
+
.map(([constructorName, index]) => this._putIndex(this.models[constructorName], index)),
|
251
|
+
),
|
252
|
+
Promise.all(
|
253
|
+
Object.entries(searchIndexCache)
|
254
|
+
.map(([constructorName, index]) => this._putSearchIndex(this.models[constructorName], index)),
|
255
|
+
),
|
256
|
+
]);
|
257
|
+
}
|
258
|
+
|
259
|
+
/**
|
260
|
+
* Get the model constructor from a model id
|
261
|
+
* @param {string} modelId
|
262
|
+
* @return {Model.constructor}
|
263
|
+
*/
|
264
|
+
getModelConstructorFromId(modelId) {
|
265
|
+
const modelName = modelId.split('/')[0];
|
266
|
+
const constructor = this.models[modelName];
|
267
|
+
|
268
|
+
if (!constructor) throw new ModelNotRegisteredStorageEngineError(modelName, this);
|
269
|
+
|
270
|
+
return constructor;
|
271
|
+
}
|
272
|
+
|
273
|
+
/**
|
274
|
+
* Get model instance that are directly linked to the given model in either direction
|
275
|
+
* @param {Type.Model} model
|
276
|
+
* @param {object} cache
|
277
|
+
* @return {Record<string, Record<string, Type.Model>>}
|
278
|
+
*/
|
279
|
+
async getInstancesLinkedTo(model, cache = {}) {
|
280
|
+
return Object.fromEntries(
|
281
|
+
Object.entries(
|
282
|
+
await Promise.all(
|
283
|
+
Object.entries(this.getLinksFor(model.constructor))
|
284
|
+
.map(([name, _index]) =>
|
285
|
+
cache[name] ? Promise.resolve([name, Object.values(cache[name])]) :
|
286
|
+
this._getIndex(this.models[name])
|
287
|
+
.then(i => {
|
288
|
+
cache[name] = i;
|
289
|
+
return [name, Object.values(i)];
|
290
|
+
}),
|
291
|
+
),
|
292
|
+
).then(Object.fromEntries),
|
293
|
+
).map(([name, index]) => [
|
294
|
+
name,
|
295
|
+
index.map(item => Object.fromEntries(
|
296
|
+
Object.entries(item)
|
297
|
+
.filter(([propertyName, property]) => propertyName === 'id' || property?.id === model.id),
|
298
|
+
)).filter(item => Object.keys(item).length > 1),
|
299
|
+
]),
|
300
|
+
);
|
301
|
+
}
|
302
|
+
|
303
|
+
/**
|
304
|
+
* Get model classes that are directly linked to the given model in either direction
|
305
|
+
* @param {Type.Model.constructor} model
|
306
|
+
* @return {Record<string, Record<string, Type.Model.constructor>>}
|
307
|
+
*/
|
308
|
+
getLinksFor(model) {
|
309
|
+
return Object.fromEntries(
|
310
|
+
Object.entries(this.getAllModelLinks())
|
311
|
+
.filter(([modelName, links]) =>
|
312
|
+
model.name === modelName ||
|
313
|
+
Object.values(links).some((link) => link.name === model.name),
|
314
|
+
),
|
315
|
+
);
|
316
|
+
}
|
317
|
+
|
318
|
+
/**
|
319
|
+
* Get all model links
|
320
|
+
* @return {Record<string, Record<string, Type.Model.constructor>>}
|
321
|
+
*/
|
322
|
+
getAllModelLinks() {
|
323
|
+
return Object.entries(this.models)
|
324
|
+
.map(([registeredModelName, registeredModelClass]) =>
|
325
|
+
Object.entries(registeredModelClass)
|
326
|
+
.map(([propertyName, propertyType]) => [
|
327
|
+
registeredModelName,
|
328
|
+
propertyName,
|
329
|
+
typeof propertyType === 'function' &&
|
330
|
+
!/^class/.test(Function.prototype.toString.call(propertyType)) &&
|
331
|
+
!Type.Model.isModel(propertyType) ?
|
332
|
+
propertyType() : propertyType,
|
333
|
+
])
|
334
|
+
.filter(([_m, _p, type]) => Type.Model.isModel(type))
|
335
|
+
.map(([containingModel, propertyName, propertyType]) => ({
|
336
|
+
containingModel,
|
337
|
+
propertyName,
|
338
|
+
propertyType,
|
339
|
+
})),
|
340
|
+
)
|
341
|
+
.flat()
|
342
|
+
.reduce((accumulator, {containingModel, propertyName, propertyType}) => ({
|
343
|
+
...accumulator,
|
344
|
+
[containingModel]: {
|
345
|
+
...accumulator[containingModel] || {},
|
346
|
+
[propertyName]: propertyType,
|
347
|
+
},
|
348
|
+
}), {});
|
349
|
+
}
|
350
|
+
|
351
|
+
/**
|
352
|
+
* Update a model
|
353
|
+
* @param {Model} _model
|
354
|
+
* @throws MethodNotImplementedStorageEngineError
|
355
|
+
* @return Promise<void>
|
356
|
+
*/
|
357
|
+
_putModel(_model) {
|
358
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_putModel', this));
|
359
|
+
}
|
360
|
+
|
361
|
+
/**
|
362
|
+
* Get a model
|
363
|
+
* @param {string} _id
|
364
|
+
* @throws MethodNotImplementedStorageEngineError
|
365
|
+
* @throws ModelNotFoundStorageEngineError
|
366
|
+
* @return Promise<Model>
|
367
|
+
*/
|
368
|
+
_getModel(_id) {
|
369
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_getModel', this));
|
370
|
+
}
|
371
|
+
|
372
|
+
/**
|
373
|
+
* Delete a model
|
374
|
+
* @param {string} _id
|
375
|
+
* @throws MethodNotImplementedStorageEngineError
|
376
|
+
* @throws ModelNotFoundStorageEngineError
|
377
|
+
* @return Promise<void>
|
378
|
+
*/
|
379
|
+
_deleteModel(_id) {
|
380
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_deleteModel', this));
|
381
|
+
}
|
382
|
+
|
383
|
+
/**
|
384
|
+
* Get a model's index data
|
385
|
+
* @param {Model.constructor} _modelConstructor
|
386
|
+
* @throws MethodNotImplementedStorageEngineError
|
387
|
+
* @return Promise<void>
|
388
|
+
*/
|
389
|
+
_getIndex(_modelConstructor) {
|
390
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_getIndex', this));
|
391
|
+
}
|
392
|
+
|
393
|
+
/**
|
394
|
+
* Put a model's index data
|
395
|
+
* @param {Model.constructor} _modelConstructor
|
396
|
+
* @param {object} _data
|
397
|
+
* @throws MethodNotImplementedStorageEngineError
|
398
|
+
* @return Promise<void>
|
399
|
+
*/
|
400
|
+
_putIndex(_modelConstructor, _data) {
|
401
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_putIndex', this));
|
402
|
+
}
|
403
|
+
|
404
|
+
/**
|
405
|
+
* Get a model's raw search index data
|
406
|
+
* @param {Model.constructor} _modelConstructor
|
407
|
+
* @throws MethodNotImplementedStorageEngineError
|
408
|
+
* @return Promise<void>
|
409
|
+
*/
|
410
|
+
_getSearchIndex(_modelConstructor) {
|
411
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_getSearchIndex', this));
|
412
|
+
}
|
413
|
+
|
414
|
+
/**
|
415
|
+
* Get a model's raw search index data
|
416
|
+
* @param {Model.constructor} _modelConstructor
|
417
|
+
* @throws MethodNotImplementedStorageEngineError
|
418
|
+
* @return Promise<void>
|
419
|
+
*/
|
420
|
+
_getSearchIndexCompiled(_modelConstructor) {
|
421
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_getSearchIndexCompiled', this));
|
422
|
+
}
|
423
|
+
|
424
|
+
/**
|
425
|
+
* Put a model's raw and compiled search index data
|
426
|
+
* @param {Model.constructor} _modelConstructor
|
427
|
+
* @param {object} _data
|
428
|
+
* @throws MethodNotImplementedStorageEngineError
|
429
|
+
* @return Promise<void>
|
430
|
+
*/
|
431
|
+
_putSearchIndex(_modelConstructor, _data) {
|
432
|
+
return Promise.reject(new MethodNotImplementedStorageEngineError('_putSearchIndex', this));
|
433
|
+
}
|
434
|
+
}
|
435
|
+
|
436
|
+
|
437
|
+
/**
|
438
|
+
* Decide if two models indexable fields are different
|
439
|
+
* @param {Type.Model} currentModel
|
440
|
+
* @param {Type.Model} modelToProcess
|
441
|
+
* @return {boolean}
|
442
|
+
* @private
|
443
|
+
*/
|
444
|
+
function indexedFieldsHaveChanged(currentModel, modelToProcess) {
|
445
|
+
return !currentModel || JSON.stringify(currentModel.toIndexData()) !== JSON.stringify(modelToProcess.toIndexData());
|
446
|
+
}
|
447
|
+
|
448
|
+
/**
|
449
|
+
* Decide if two models searchable fields have changed
|
450
|
+
* @param {Type.Model} currentModel
|
451
|
+
* @param {Type.Model} modelToProcess
|
452
|
+
* @return {boolean}
|
453
|
+
* @private
|
454
|
+
*/
|
455
|
+
function searchableFieldsHaveChanged(currentModel, modelToProcess) {
|
456
|
+
return !currentModel || JSON.stringify(currentModel.toSearchData()) !== JSON.stringify(modelToProcess.toSearchData());
|
457
|
+
}
|
458
|
+
|
459
|
+
/**
|
460
|
+
* @class StorageEngineError
|
461
|
+
* @extends Error
|
462
|
+
*/
|
463
|
+
export class StorageEngineError extends Error {
|
464
|
+
}
|
465
|
+
|
466
|
+
/**
|
467
|
+
* @class ModelNotRegisteredStorageEngineError
|
468
|
+
* @extends StorageEngineError
|
469
|
+
*/
|
470
|
+
export class ModelNotRegisteredStorageEngineError extends StorageEngineError {
|
471
|
+
/**
|
472
|
+
* @param {Type.Model} model
|
473
|
+
* @param {StorageEngine} storageEngine
|
474
|
+
*/
|
475
|
+
constructor(model, storageEngine) {
|
476
|
+
const modelName = typeof model === 'string' ? model : model.constructor.name;
|
477
|
+
super(`The model ${modelName} is not registered in the storage engine ${storageEngine.constructor.name}`);
|
478
|
+
}
|
479
|
+
}
|
480
|
+
|
481
|
+
/**
|
482
|
+
* @class MethodNotImplementedStorageEngineError
|
483
|
+
* @extends StorageEngineError
|
484
|
+
*/
|
485
|
+
export class MethodNotImplementedStorageEngineError extends StorageEngineError {
|
486
|
+
/**
|
487
|
+
* @param {string} method
|
488
|
+
* @param {StorageEngine} storageEngine
|
489
|
+
*/
|
490
|
+
constructor(method, storageEngine) {
|
491
|
+
super(`The method ${method} is not implemented in the storage engine ${storageEngine.constructor.name}`);
|
492
|
+
}
|
493
|
+
}
|
494
|
+
|
495
|
+
/**
|
496
|
+
* @class ModelNotFoundStorageEngineError
|
497
|
+
* @extends StorageEngineError
|
498
|
+
*/
|
499
|
+
export class ModelNotFoundStorageEngineError extends StorageEngineError {
|
500
|
+
/**
|
501
|
+
* @param {string} modelId
|
502
|
+
*/
|
503
|
+
constructor(modelId) {
|
504
|
+
super(`The model ${modelId} was not found`);
|
505
|
+
}
|
506
|
+
}
|
507
|
+
|
508
|
+
export class DeleteHasUnintendedConsequencesStorageEngineError extends StorageEngineError {
|
509
|
+
/**
|
510
|
+
* @param {string} modelId
|
511
|
+
* @param {object} consequences
|
512
|
+
*/
|
513
|
+
constructor(modelId, consequences) {
|
514
|
+
super(`Deleting ${modelId} has unintended consequences`);
|
515
|
+
this.consequences = consequences;
|
516
|
+
}
|
517
|
+
}
|
@@ -113,6 +113,13 @@ class FileStorageEngine extends StorageEngine {
|
|
113
113
|
* @throws {FailedWriteFileStorageEngineError} Throws if the index cannot be written to the file system.
|
114
114
|
*/
|
115
115
|
static async putIndex(index) {
|
116
|
+
/**
|
117
|
+
* Process an index of models
|
118
|
+
* @param {string} location
|
119
|
+
* @param {Array<Model>} models
|
120
|
+
* @throws FailedWriteFileStorageEngineError
|
121
|
+
* @return {Promise<void>}
|
122
|
+
*/
|
116
123
|
const processIndex = async (location, models) => {
|
117
124
|
const filePath = join(this.configuration.path, location, '_index.json');
|
118
125
|
const currentIndex = JSON.parse((await this.configuration.filesystem.readFile(filePath).catch(() => '{}')).toString());
|
@@ -186,6 +186,12 @@ class HTTPStorageEngine extends StorageEngine {
|
|
186
186
|
* @throws {HTTPRequestFailedError} Thrown if the PUT request fails.
|
187
187
|
*/
|
188
188
|
static async putIndex(index) {
|
189
|
+
/**
|
190
|
+
* Process an index of models
|
191
|
+
* @param {string} location
|
192
|
+
* @param {Array<Model>} models
|
193
|
+
* @return {Promise<void>}
|
194
|
+
*/
|
189
195
|
const processIndex = async (location, models) => {
|
190
196
|
const url = new URL([
|
191
197
|
this.configuration.host,
|
@@ -136,6 +136,13 @@ class S3StorageEngine extends StorageEngine {
|
|
136
136
|
* @throws {FailedPutS3StorageEngineError} Thrown if there is an error during the S3 PutObject operation.
|
137
137
|
*/
|
138
138
|
static async putIndex(index) {
|
139
|
+
/**
|
140
|
+
* Process an index of models
|
141
|
+
* @param {string} location
|
142
|
+
* @param {Array<Model>} models
|
143
|
+
* @throws FailedPutS3StorageEngineError
|
144
|
+
* @return {Promise<void>}
|
145
|
+
*/
|
139
146
|
const processIndex = async (location, models) => {
|
140
147
|
const Key = [this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/');
|
141
148
|
const currentIndex = await this.getIndex(location);
|
@@ -186,6 +186,11 @@ class StorageEngine {
|
|
186
186
|
const uploadedModels = [];
|
187
187
|
const indexUpdates = {};
|
188
188
|
|
189
|
+
/**
|
190
|
+
* Process a model, putting updates to the model and all linked models.
|
191
|
+
* @param {Model} m
|
192
|
+
* @return {Promise<void>}
|
193
|
+
*/
|
189
194
|
const processModel = async (m) => {
|
190
195
|
if (!uploadedModels.includes(m.id)) {
|
191
196
|
m.validate();
|
@@ -389,6 +394,11 @@ class StorageEngine {
|
|
389
394
|
this.checkConfiguration();
|
390
395
|
const hydratedModels = {};
|
391
396
|
|
397
|
+
/**
|
398
|
+
* Hydrate a model
|
399
|
+
* @param {Model} modelToProcess
|
400
|
+
* @return {Promise<Model>}
|
401
|
+
*/
|
392
402
|
const hydrateModel = async (modelToProcess) => {
|
393
403
|
hydratedModels[modelToProcess.id] = modelToProcess;
|
394
404
|
|
@@ -405,6 +415,13 @@ class StorageEngine {
|
|
405
415
|
return modelToProcess;
|
406
416
|
};
|
407
417
|
|
418
|
+
/**
|
419
|
+
* Hydrate a dry sub model
|
420
|
+
* @param property
|
421
|
+
* @param modelToProcess
|
422
|
+
* @param name
|
423
|
+
* @return {Promise<Model>}
|
424
|
+
*/
|
408
425
|
const hydrateSubModel = async (property, modelToProcess, name) => {
|
409
426
|
if (hydratedModels[property.id]) {
|
410
427
|
return hydratedModels[property.id];
|
@@ -418,6 +435,13 @@ class StorageEngine {
|
|
418
435
|
return hydratedSubModel;
|
419
436
|
};
|
420
437
|
|
438
|
+
/**
|
439
|
+
* Hydrate an array of dry models
|
440
|
+
* @param property
|
441
|
+
* @param modelToProcess
|
442
|
+
* @param name
|
443
|
+
* @return {Promise<Awaited<*>[]>}
|
444
|
+
*/
|
421
445
|
const hydrateModelList = async (property, modelToProcess, name) => {
|
422
446
|
const subModelClass = getSubModelClass(modelToProcess, name, true);
|
423
447
|
|
@@ -440,6 +464,13 @@ class StorageEngine {
|
|
440
464
|
}));
|
441
465
|
};
|
442
466
|
|
467
|
+
/**
|
468
|
+
* Get the class of a sub model
|
469
|
+
* @param modelToProcess
|
470
|
+
* @param name
|
471
|
+
* @param isArray
|
472
|
+
* @return {Model.constructor|Type}
|
473
|
+
*/
|
443
474
|
function getSubModelClass(modelToProcess, name, isArray = false) {
|
444
475
|
const constructorField = modelToProcess.constructor[name];
|
445
476
|
|
@@ -460,6 +491,10 @@ class StorageEngine {
|
|
460
491
|
* @returns {StorageEngine} A new engine instance with the applied configuration.
|
461
492
|
*/
|
462
493
|
static configure(configuration) {
|
494
|
+
/**
|
495
|
+
* @class ConfiguredStore
|
496
|
+
* @extends StorageEngine
|
497
|
+
*/
|
463
498
|
class ConfiguredStore extends this {
|
464
499
|
static configuration = configuration;
|
465
500
|
}
|
package/src/type/Model.js
CHANGED
@@ -100,11 +100,11 @@ class Model {
|
|
100
100
|
|
101
101
|
/**
|
102
102
|
* Extracts data from the model based on the indexed properties defined in the class.
|
103
|
-
*
|
103
|
+
* Includes the ID of any linked models.
|
104
104
|
* @returns {Object} - A representation of the model's indexed data.
|
105
105
|
*/
|
106
106
|
toIndexData() {
|
107
|
-
return this._extractData(this.constructor.
|
107
|
+
return this._extractData(this.constructor.indexedPropertiesResolved());
|
108
108
|
}
|
109
109
|
|
110
110
|
/**
|
@@ -187,6 +187,39 @@ class Model {
|
|
187
187
|
return [];
|
188
188
|
}
|
189
189
|
|
190
|
+
/**
|
191
|
+
* Returns a list of properties that are indexed including links to other models.
|
192
|
+
*
|
193
|
+
* @returns {Array<string>} - The indexed properties.
|
194
|
+
* @abstract
|
195
|
+
* @static
|
196
|
+
*/
|
197
|
+
static indexedPropertiesResolved() {
|
198
|
+
return []
|
199
|
+
.concat(
|
200
|
+
Object.entries(this)
|
201
|
+
.filter(([_name, type]) =>
|
202
|
+
this.isModel(
|
203
|
+
typeof type === 'function' &&
|
204
|
+
!/^class/.test(Function.prototype.toString.call(type)) ?
|
205
|
+
type() : type,
|
206
|
+
),
|
207
|
+
)
|
208
|
+
.map(([name, _type]) => `${name}.id`),
|
209
|
+
)
|
210
|
+
.concat(
|
211
|
+
Object.entries(this)
|
212
|
+
.filter(([_name, type]) =>
|
213
|
+
type?._type === 'array' && this.isModel(
|
214
|
+
typeof type._items === 'function' &&
|
215
|
+
!/^class/.test(Function.prototype.toString.call(type._items)) ?
|
216
|
+
type._items() : type._items,
|
217
|
+
),
|
218
|
+
).map(([name, _type]) => `${name}.[*].id`),
|
219
|
+
)
|
220
|
+
.concat(this.indexedProperties());
|
221
|
+
}
|
222
|
+
|
190
223
|
/**
|
191
224
|
* Returns a list of properties used for search.
|
192
225
|
*
|
@@ -1,15 +1,15 @@
|
|
1
|
-
import
|
1
|
+
import Type from '../Type.js';
|
2
2
|
|
3
3
|
/**
|
4
4
|
* Class representing a boolean type.
|
5
5
|
*
|
6
6
|
* This class is used to define and handle data of the boolean type.
|
7
|
-
* It extends the {@link
|
7
|
+
* It extends the {@link Type} class to represent string-specific behavior.
|
8
8
|
*
|
9
9
|
* @class BooleanType
|
10
|
-
* @extends
|
10
|
+
* @extends Type
|
11
11
|
*/
|
12
|
-
class BooleanType extends
|
12
|
+
class BooleanType extends Type {
|
13
13
|
static {
|
14
14
|
/**
|
15
15
|
* @static
|
@@ -1,15 +1,15 @@
|
|
1
|
-
import
|
1
|
+
import Type from '../Type.js';
|
2
2
|
|
3
3
|
/**
|
4
4
|
* Class representing a date type with ISO date-time format.
|
5
5
|
*
|
6
6
|
* This class is used to define and handle data of the date type.
|
7
|
-
* It extends the {@link
|
7
|
+
* It extends the {@link Type} class to represent string-specific behavior.
|
8
8
|
*
|
9
9
|
* @class DateType
|
10
|
-
* @extends
|
10
|
+
* @extends Type
|
11
11
|
*/
|
12
|
-
class DateType extends
|
12
|
+
class DateType extends Type {
|
13
13
|
static {
|
14
14
|
/**
|
15
15
|
* @static
|
@@ -1,15 +1,15 @@
|
|
1
|
-
import
|
1
|
+
import Type from '../Type.js';
|
2
2
|
|
3
3
|
/**
|
4
4
|
* Class representing a number type.
|
5
5
|
*
|
6
6
|
* This class is used to define and handle data of the number type.
|
7
|
-
* It extends the {@link
|
7
|
+
* It extends the {@link Type} class to represent string-specific behavior.
|
8
8
|
*
|
9
9
|
* @class NumberType
|
10
|
-
* @extends
|
10
|
+
* @extends Type
|
11
11
|
*/
|
12
|
-
class NumberType extends
|
12
|
+
class NumberType extends Type {
|
13
13
|
static {
|
14
14
|
/**
|
15
15
|
* @static
|
@@ -1,15 +1,15 @@
|
|
1
|
-
import
|
1
|
+
import Type from '../Type.js';
|
2
2
|
|
3
3
|
/**
|
4
4
|
* Class representing a string type.
|
5
5
|
*
|
6
6
|
* This class is used to define and handle data of the string type.
|
7
|
-
* It extends the {@link
|
7
|
+
* It extends the {@link Type} class to represent string-specific behavior.
|
8
8
|
*
|
9
9
|
* @class StringType
|
10
|
-
* @extends
|
10
|
+
* @extends Type
|
11
11
|
*/
|
12
|
-
class StringType extends
|
12
|
+
class StringType extends Type {
|
13
13
|
static {
|
14
14
|
/**
|
15
15
|
* @static
|
@@ -1,14 +0,0 @@
|
|
1
|
-
import Type from '../Type.js';
|
2
|
-
|
3
|
-
/**
|
4
|
-
* Class representing a simple type.
|
5
|
-
*
|
6
|
-
* This serves as a base class for primitive or simple types such as string, number, or boolean.
|
7
|
-
*
|
8
|
-
* @class SimpleType
|
9
|
-
* @extends Type
|
10
|
-
*/
|
11
|
-
class SimpleType extends Type {
|
12
|
-
}
|
13
|
-
|
14
|
-
export default SimpleType;
|