@acodeninja/persist 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/exports/default.js +3 -0
- package/exports/engine/file.js +3 -0
- package/exports/engine/s3.js +3 -0
- package/package.json +50 -0
- package/src/Persist.js +40 -0
- package/src/SchemaCompiler.js +128 -0
- package/src/engine/Engine.js +215 -0
- package/src/engine/FileEngine.js +79 -0
- package/src/engine/S3Engine.js +124 -0
- package/src/type/Model.js +115 -0
- package/src/type/Type.js +33 -0
- package/src/type/complex/ArrayType.js +30 -0
- package/src/type/complex/CustomType.js +15 -0
- package/src/type/index.js +19 -0
- package/src/type/resolved/ResolvedType.js +19 -0
- package/src/type/resolved/SlugType.js +24 -0
- package/src/type/simple/BooleanType.js +5 -0
- package/src/type/simple/NumberType.js +5 -0
- package/src/type/simple/SimpleType.js +4 -0
- package/src/type/simple/StringType.js +5 -0
package/README.md
ADDED
@@ -0,0 +1,193 @@
|
|
1
|
+
# @acodeninja/persist
|
2
|
+
|
3
|
+
A JSON based data modelling and persistence library with alternate storage mechanisms.
|
4
|
+
|
5
|
+
## Models
|
6
|
+
|
7
|
+
The `Model` and `Type` classes allow creating representations of data objects
|
8
|
+
|
9
|
+
### Defining Models
|
10
|
+
|
11
|
+
##### A model using all available basic types
|
12
|
+
|
13
|
+
```javascript
|
14
|
+
import Persist from "@acodeninja/persist";
|
15
|
+
|
16
|
+
export class SimpleModel extends Persist.Type.Model {
|
17
|
+
static boolean = Persist.Type.Boolean;
|
18
|
+
static string = Persist.Type.String;
|
19
|
+
static number = Persist.Type.Number;
|
20
|
+
}
|
21
|
+
```
|
22
|
+
|
23
|
+
##### A simple model using required types
|
24
|
+
|
25
|
+
```javascript
|
26
|
+
import Persist from "@acodeninja/persist";
|
27
|
+
|
28
|
+
export class SimpleModel extends Persist.Type.Model {
|
29
|
+
static requiredBoolean = Persist.Type.Boolean.required;
|
30
|
+
static requiredString = Persist.Type.String.required;
|
31
|
+
static requiredNumber = Persist.Type.Number.required;
|
32
|
+
}
|
33
|
+
```
|
34
|
+
|
35
|
+
##### A simple model using arrays of basic types
|
36
|
+
|
37
|
+
```javascript
|
38
|
+
import Persist from "@acodeninja/persist";
|
39
|
+
|
40
|
+
export class SimpleModel extends Persist.Type.Model {
|
41
|
+
static arrayOfBooleans = Persist.Type.Array.of(Type.Boolean);
|
42
|
+
static arrayOfStrings = Persist.Type.Array.of(Type.String);
|
43
|
+
static arrayOfNumbers = Persist.Type.Array.of(Type.Number);
|
44
|
+
}
|
45
|
+
```
|
46
|
+
|
47
|
+
<details>
|
48
|
+
<summary>Complex relationships are also supported</summary>
|
49
|
+
|
50
|
+
#### One-to-One Relationships
|
51
|
+
|
52
|
+
##### A one-to-one relationship
|
53
|
+
|
54
|
+
```javascript
|
55
|
+
import Persist from "@acodeninja/persist";
|
56
|
+
|
57
|
+
export class ModelB extends Persist.Type.Model {
|
58
|
+
}
|
59
|
+
|
60
|
+
export class ModelA extends Persist.Type.Model {
|
61
|
+
static linked = ModelB;
|
62
|
+
}
|
63
|
+
```
|
64
|
+
|
65
|
+
##### A circular one-to-one relationship
|
66
|
+
|
67
|
+
```javascript
|
68
|
+
import Persist from "@acodeninja/persist";
|
69
|
+
|
70
|
+
export class ModelA extends Persist.Type.Model {
|
71
|
+
static linked = () => ModelB;
|
72
|
+
}
|
73
|
+
|
74
|
+
export class ModelB extends Persist.Type.Model {
|
75
|
+
static linked = ModelA;
|
76
|
+
}
|
77
|
+
```
|
78
|
+
|
79
|
+
#### One-to-Many Relationships
|
80
|
+
|
81
|
+
##### A one-to-many relationship
|
82
|
+
|
83
|
+
```javascript
|
84
|
+
import Persist from "@acodeninja/persist";
|
85
|
+
|
86
|
+
export class ModelB extends Persist.Type.Model {
|
87
|
+
}
|
88
|
+
|
89
|
+
export class ModelA extends Persist.Type.Model {
|
90
|
+
static linked = Persist.Type.Array.of(ModelB);
|
91
|
+
}
|
92
|
+
```
|
93
|
+
|
94
|
+
##### A circular one-to-many relationship
|
95
|
+
|
96
|
+
```javascript
|
97
|
+
import Persist from "@acodeninja/persist";
|
98
|
+
|
99
|
+
export class ModelA extends Persist.Type.Model {
|
100
|
+
static linked = () => Type.Array.of(ModelB);
|
101
|
+
}
|
102
|
+
|
103
|
+
export class ModelB extends Persist.Type.Model {
|
104
|
+
static linked = ModelA;
|
105
|
+
}
|
106
|
+
```
|
107
|
+
|
108
|
+
#### Many-to-Many Relationships
|
109
|
+
|
110
|
+
##### A many-to-many relationship
|
111
|
+
|
112
|
+
```javascript
|
113
|
+
import Persist from "@acodeninja/persist";
|
114
|
+
|
115
|
+
export class ModelA extends Persist.Type.Model {
|
116
|
+
static linked = Persist.Type.Array.of(ModelB);
|
117
|
+
}
|
118
|
+
|
119
|
+
export class ModelB extends Persist.Type.Model {
|
120
|
+
static linked = Persist.Type.Array.of(ModelA);
|
121
|
+
}
|
122
|
+
```
|
123
|
+
</details>
|
124
|
+
|
125
|
+
## Find and Search
|
126
|
+
|
127
|
+
Models may expose a `searchProperties()` and `indexProperties()` static method to indicate which
|
128
|
+
fields should be indexed for storage engine `find()` and `search()` methods.
|
129
|
+
|
130
|
+
Use `find()` for a low usage exact string match on any indexed attribute of a model.
|
131
|
+
|
132
|
+
Use `search()` for a medium usage fuzzy string match on any search indexed attribute of a model.
|
133
|
+
|
134
|
+
```javascript
|
135
|
+
import Persist from "@acodeninja/persist";
|
136
|
+
import FileEngine from "@acodeninja/persist/engine/file";
|
137
|
+
|
138
|
+
export class Tag extends Persist.Type.Model {
|
139
|
+
static tag = Persist.Type.String.required;
|
140
|
+
static description = Persist.Type.String;
|
141
|
+
static searchProperties = () => ['tag', 'description'];
|
142
|
+
static indexProperties = () => ['tag'];
|
143
|
+
}
|
144
|
+
|
145
|
+
const tag = new Tag({tag: 'documentation', description: 'How to use the persist library'});
|
146
|
+
|
147
|
+
FileEngine.find(Tag, {tag: 'documentation'});
|
148
|
+
// [Tag {tag: 'documentation', description: 'How to use the persist library'}]
|
149
|
+
|
150
|
+
FileEngine.search(Tag, 'how to');
|
151
|
+
// [Tag {tag: 'documentation', description: 'How to use the persist library'}]
|
152
|
+
```
|
153
|
+
|
154
|
+
## Storage
|
155
|
+
|
156
|
+
### Filesystem Storage Engine
|
157
|
+
|
158
|
+
To store models using the local file system, use the `File` storage engine.
|
159
|
+
|
160
|
+
```javascript
|
161
|
+
import Persist from "@acodeninja/persist";
|
162
|
+
import FileEngine from "@acodeninja/persist/engine/file";
|
163
|
+
|
164
|
+
Persist.addEngine('local', FileEngine, {
|
165
|
+
path: '/app/storage',
|
166
|
+
});
|
167
|
+
|
168
|
+
export class Tag extends Persist.Type.Model {
|
169
|
+
static tag = Persist.Type.String.required;
|
170
|
+
}
|
171
|
+
|
172
|
+
Persist.getEngine('local', FileEngine).put(new Tag({tag: 'documentation'}));
|
173
|
+
```
|
174
|
+
|
175
|
+
### S3 Storage Engine
|
176
|
+
|
177
|
+
To store models using an S3 Bucket, use the `S3` storage engine.
|
178
|
+
|
179
|
+
```javascript
|
180
|
+
import Persist from "@acodeninja/persist";
|
181
|
+
import S3Engine from "@acodeninja/persist/engine/s3";
|
182
|
+
|
183
|
+
Persist.addEngine('remote', S3Engine, {
|
184
|
+
bucket: 'test-bucket',
|
185
|
+
client: new S3Client(),
|
186
|
+
});
|
187
|
+
|
188
|
+
export class Tag extends Persist.Type.Model {
|
189
|
+
static tag = Persist.Type.String.required;
|
190
|
+
}
|
191
|
+
|
192
|
+
Persist.getEngine('remote', S3Engine).put(new Tag({tag: 'documentation'}));
|
193
|
+
```
|
package/package.json
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
{
|
2
|
+
"name": "@acodeninja/persist",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "A JSON based data modelling and persistence module with alternate storage mechanisms.",
|
5
|
+
"type": "module",
|
6
|
+
"scripts": {
|
7
|
+
"test": "ava",
|
8
|
+
"test:watch": "ava --watch",
|
9
|
+
"test:coverage": "c8 --experimental-monocart --100 --reporter=console-details ava",
|
10
|
+
"test:coverage:report": "c8 --experimental-monocart --100 --lcov --reporter=console-details --reporter=v8 ava",
|
11
|
+
"lint": "eslint ./",
|
12
|
+
"prepare": "husky"
|
13
|
+
},
|
14
|
+
"exports": {
|
15
|
+
".": "./exports/default.js",
|
16
|
+
"./engine/*": "./exports/engine/*.js"
|
17
|
+
},
|
18
|
+
"repository": {
|
19
|
+
"url": "https://github.com/acodeninja/persist"
|
20
|
+
},
|
21
|
+
"publishConfig": {
|
22
|
+
"access": "public",
|
23
|
+
"provenance": true
|
24
|
+
},
|
25
|
+
"dependencies": {
|
26
|
+
"ajv": "^8.16.0",
|
27
|
+
"ajv-errors": "^3.0.0",
|
28
|
+
"lunr": "^2.3.9",
|
29
|
+
"slugify": "^1.6.6",
|
30
|
+
"ulid": "^2.3.0"
|
31
|
+
},
|
32
|
+
"optionalDependencies": {
|
33
|
+
"@aws-sdk/client-s3": "^3.614.0"
|
34
|
+
},
|
35
|
+
"devDependencies": {
|
36
|
+
"@commitlint/cli": "^19.3.0",
|
37
|
+
"@commitlint/config-conventional": "^19.2.2",
|
38
|
+
"@eslint/js": "^9.6.0",
|
39
|
+
"@semantic-release/commit-analyzer": "^13.0.0",
|
40
|
+
"ava": "^6.1.3",
|
41
|
+
"c8": "^10.1.2",
|
42
|
+
"eslint": "^9.6.0",
|
43
|
+
"globals": "^15.8.0",
|
44
|
+
"husky": "^9.1.1",
|
45
|
+
"lodash": "^4.17.21",
|
46
|
+
"monocart-coverage-reports": "^2.9.2",
|
47
|
+
"semantic-release": "^24.0.0",
|
48
|
+
"sinon": "^18.0.0"
|
49
|
+
}
|
50
|
+
}
|
package/src/Persist.js
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
import Type from '../src/type/index.js';
|
2
|
+
|
3
|
+
/**
|
4
|
+
* @class Persist
|
5
|
+
*/
|
6
|
+
export default class Persist {
|
7
|
+
static _engine = {};
|
8
|
+
/**
|
9
|
+
* @memberof Persist
|
10
|
+
* @type {Type}
|
11
|
+
* @static
|
12
|
+
*/
|
13
|
+
static Type = Type;
|
14
|
+
|
15
|
+
/**
|
16
|
+
* @function getEngine
|
17
|
+
* @memberof Persist
|
18
|
+
* @static
|
19
|
+
* @param {string} group - Name of the group containing the engine
|
20
|
+
* @param {Engine} engine - The engine class you wish to retrieve
|
21
|
+
* @return {Engine|null}
|
22
|
+
*/
|
23
|
+
static getEngine(group, engine) {
|
24
|
+
return this._engine[group]?.[engine.name] ?? null;
|
25
|
+
}
|
26
|
+
|
27
|
+
/**
|
28
|
+
* @function addEngine
|
29
|
+
* @memberof Persist
|
30
|
+
* @static
|
31
|
+
* @param {string} group - Name of the group containing the engine
|
32
|
+
* @param {Engine} engine - The engine class you wish to configure and add to the group
|
33
|
+
* @param {object?} configuration - The configuration to use with the engine
|
34
|
+
*/
|
35
|
+
static addEngine(group, engine, configuration) {
|
36
|
+
if (!this._engine[group]) this._engine[group] = {};
|
37
|
+
|
38
|
+
this._engine[group][engine.name] = engine.configure(configuration);
|
39
|
+
}
|
40
|
+
}
|
@@ -0,0 +1,128 @@
|
|
1
|
+
import Type from './type/index.js';
|
2
|
+
import ajv from 'ajv';
|
3
|
+
import ajvErrors from 'ajv-errors';
|
4
|
+
|
5
|
+
/**
|
6
|
+
* @class SchemaCompiler
|
7
|
+
*/
|
8
|
+
export default class SchemaCompiler {
|
9
|
+
/**
|
10
|
+
* @method compile
|
11
|
+
* @param {Model|object} rawSchema
|
12
|
+
* @return {CompiledSchema}
|
13
|
+
*/
|
14
|
+
static compile(rawSchema) {
|
15
|
+
const validation = new ajv({allErrors: true});
|
16
|
+
|
17
|
+
ajvErrors(validation);
|
18
|
+
|
19
|
+
const schema = {
|
20
|
+
type: 'object',
|
21
|
+
additionalProperties: false,
|
22
|
+
properties: {},
|
23
|
+
required: [],
|
24
|
+
};
|
25
|
+
|
26
|
+
if (Type.Model.isModel(rawSchema)) {
|
27
|
+
schema.required.push('id');
|
28
|
+
schema.properties['id'] = {type: 'string'};
|
29
|
+
}
|
30
|
+
|
31
|
+
for (const [name, type] of Object.entries(rawSchema)) {
|
32
|
+
if (['indexedProperties', 'searchProperties'].includes(name)) continue;
|
33
|
+
|
34
|
+
const property = type instanceof Function && !type.prototype ? type() : type;
|
35
|
+
|
36
|
+
if (property?._required || property?._items?._type?._required)
|
37
|
+
schema.required.push(name);
|
38
|
+
|
39
|
+
if (Type.Model.isModel(property)) {
|
40
|
+
schema.properties[name] = {
|
41
|
+
type: 'object',
|
42
|
+
additionalProperties: false,
|
43
|
+
required: ['id'],
|
44
|
+
properties: {
|
45
|
+
id: {
|
46
|
+
type: 'string',
|
47
|
+
pattern: `^${property.toString()}/[A-Z0-9]+$`,
|
48
|
+
},
|
49
|
+
},
|
50
|
+
};
|
51
|
+
continue;
|
52
|
+
}
|
53
|
+
|
54
|
+
if (property?._schema) {
|
55
|
+
schema.properties[name] = property._schema;
|
56
|
+
continue;
|
57
|
+
}
|
58
|
+
|
59
|
+
schema.properties[name] = {type: property?._type};
|
60
|
+
|
61
|
+
if (property?._type === 'array') {
|
62
|
+
schema.properties[name].items = {type: property?._items._type};
|
63
|
+
|
64
|
+
if (Type.Model.isModel(property?._items)) {
|
65
|
+
schema.properties[name].items = {
|
66
|
+
type: 'object',
|
67
|
+
additionalProperties: false,
|
68
|
+
required: ['id'],
|
69
|
+
properties: {
|
70
|
+
id: {
|
71
|
+
type: 'string',
|
72
|
+
pattern: `^${property?._items.toString()}/[A-Z0-9]+$`,
|
73
|
+
},
|
74
|
+
},
|
75
|
+
};
|
76
|
+
}
|
77
|
+
}
|
78
|
+
}
|
79
|
+
|
80
|
+
class Schema extends CompiledSchema {
|
81
|
+
static _schema = schema;
|
82
|
+
static _validator = validation.compile(schema);
|
83
|
+
}
|
84
|
+
|
85
|
+
return Schema;
|
86
|
+
}
|
87
|
+
}
|
88
|
+
|
89
|
+
/**
|
90
|
+
* @class CompiledSchema
|
91
|
+
* @property {object} _schema
|
92
|
+
* @property {Function} _validator
|
93
|
+
*/
|
94
|
+
export class CompiledSchema {
|
95
|
+
static _schema = null;
|
96
|
+
static _validator = null;
|
97
|
+
|
98
|
+
/**
|
99
|
+
* @method validate
|
100
|
+
* @param data
|
101
|
+
* @return {boolean}
|
102
|
+
* @throws {ValidationError}
|
103
|
+
*/
|
104
|
+
static validate(data) {
|
105
|
+
let inputData = data;
|
106
|
+
if (Type.Model.isModel(data)) {
|
107
|
+
inputData = data.toData();
|
108
|
+
}
|
109
|
+
const valid = this._validator?.(inputData);
|
110
|
+
if (valid) return valid;
|
111
|
+
|
112
|
+
throw new ValidationError(inputData, this._validator.errors);
|
113
|
+
}
|
114
|
+
}
|
115
|
+
|
116
|
+
/**
|
117
|
+
* @class ValidationError
|
118
|
+
* @extends Error
|
119
|
+
* @property {object[]} errors
|
120
|
+
* @property {object} data
|
121
|
+
*/
|
122
|
+
export class ValidationError extends Error {
|
123
|
+
constructor(data, errors) {
|
124
|
+
super('Validation failed');
|
125
|
+
this.errors = errors;
|
126
|
+
this.data = data;
|
127
|
+
}
|
128
|
+
}
|
@@ -0,0 +1,215 @@
|
|
1
|
+
import Type from '../type/index.js';
|
2
|
+
import lunr from 'lunr';
|
3
|
+
|
4
|
+
/**
|
5
|
+
* @class Engine
|
6
|
+
*/
|
7
|
+
export default class Engine {
|
8
|
+
static _configuration = undefined;
|
9
|
+
|
10
|
+
static async getById(_id) {
|
11
|
+
throw new NotImplementedError(`${this.name} must implement .getById()`);
|
12
|
+
}
|
13
|
+
|
14
|
+
static async putModel(_data) {
|
15
|
+
throw new NotImplementedError(`${this.name} must implement .putModel()`);
|
16
|
+
}
|
17
|
+
|
18
|
+
static async putIndex(_index) {
|
19
|
+
throw new NotImplementedError(`${this.name} does not implement .putIndex()`);
|
20
|
+
}
|
21
|
+
|
22
|
+
static async getSearchIndexCompiled(_model) {
|
23
|
+
throw new NotImplementedError(`${this.name} does not implement .getSearchIndexCompiled()`);
|
24
|
+
}
|
25
|
+
|
26
|
+
static async getSearchIndexRaw(_model) {
|
27
|
+
throw new NotImplementedError(`${this.name} does not implement .getSearchIndexRaw()`);
|
28
|
+
}
|
29
|
+
|
30
|
+
static async putSearchIndexCompiled(_model, _compiledIndex) {
|
31
|
+
throw new NotImplementedError(`${this.name} does not implement .putSearchIndexCompiled()`);
|
32
|
+
}
|
33
|
+
|
34
|
+
static async putSearchIndexRaw(_model, _rawIndex) {
|
35
|
+
throw new NotImplementedError(`${this.name} does not implement .putSearchIndexRaw()`);
|
36
|
+
}
|
37
|
+
|
38
|
+
static async findByValue(_model, _parameters) {
|
39
|
+
throw new NotImplementedError(`${this.name} does not implement .findByValue()`);
|
40
|
+
}
|
41
|
+
|
42
|
+
static async search(model, query) {
|
43
|
+
const index = await this.getSearchIndexCompiled(model);
|
44
|
+
|
45
|
+
try {
|
46
|
+
const searchIndex = lunr.Index.load(index);
|
47
|
+
|
48
|
+
const results = searchIndex.search(query);
|
49
|
+
const output = [];
|
50
|
+
for (const result of results) {
|
51
|
+
output.push({
|
52
|
+
...result,
|
53
|
+
model: await this.get(model, result.ref),
|
54
|
+
});
|
55
|
+
}
|
56
|
+
|
57
|
+
return output;
|
58
|
+
} catch (_) {
|
59
|
+
throw new NotImplementedError(`The model ${model.name} does not have a search index available.`);
|
60
|
+
}
|
61
|
+
}
|
62
|
+
|
63
|
+
static async find(model, parameters) {
|
64
|
+
const response = await this.findByValue(model, parameters);
|
65
|
+
|
66
|
+
return response.map(m => model.fromData(m));
|
67
|
+
}
|
68
|
+
|
69
|
+
static async put(model) {
|
70
|
+
const uploadedModels = [];
|
71
|
+
const indexUpdates = {};
|
72
|
+
|
73
|
+
const processModel = async (model) => {
|
74
|
+
if (uploadedModels.includes(model.id)) return false;
|
75
|
+
model.validate();
|
76
|
+
|
77
|
+
uploadedModels.push(model.id);
|
78
|
+
|
79
|
+
await this.putModel(model);
|
80
|
+
indexUpdates[model.constructor.name] = (indexUpdates[model.constructor.name] ?? []).concat([model]);
|
81
|
+
|
82
|
+
if (model.constructor.searchProperties().length > 0) {
|
83
|
+
const rawSearchIndex = {
|
84
|
+
...await this.getSearchIndexRaw(model.constructor),
|
85
|
+
[model.id]: model.toSearchData(),
|
86
|
+
};
|
87
|
+
await this.putSearchIndexRaw(model.constructor, rawSearchIndex);
|
88
|
+
|
89
|
+
const compiledIndex = lunr(function () {
|
90
|
+
this.ref('id')
|
91
|
+
|
92
|
+
for (const field of model.constructor.searchProperties()) {
|
93
|
+
this.field(field);
|
94
|
+
}
|
95
|
+
|
96
|
+
Object.values(rawSearchIndex).forEach(function (doc) {
|
97
|
+
this.add(doc);
|
98
|
+
}, this)
|
99
|
+
});
|
100
|
+
|
101
|
+
await this.putSearchIndexCompiled(model.constructor, compiledIndex);
|
102
|
+
}
|
103
|
+
|
104
|
+
for (const [_, property] of Object.entries(model)) {
|
105
|
+
if (Type.Model.isModel(property)) {
|
106
|
+
await processModel(property);
|
107
|
+
}
|
108
|
+
if (Array.isArray(property) && Type.Model.isModel(property[0])) {
|
109
|
+
for (const subModel of property) {
|
110
|
+
await processModel(subModel);
|
111
|
+
}
|
112
|
+
}
|
113
|
+
}
|
114
|
+
};
|
115
|
+
|
116
|
+
await processModel(model);
|
117
|
+
await this.putIndex(indexUpdates);
|
118
|
+
}
|
119
|
+
|
120
|
+
static async get(model, id) {
|
121
|
+
const found = await this.getById(id);
|
122
|
+
|
123
|
+
try {
|
124
|
+
return model.fromData(found);
|
125
|
+
} catch (_error) {
|
126
|
+
throw new NotFoundEngineError(`${this.name}.get(${id}) model not found`);
|
127
|
+
}
|
128
|
+
}
|
129
|
+
|
130
|
+
static async hydrate(model) {
|
131
|
+
const hydratedModels = {};
|
132
|
+
|
133
|
+
const hydrateModel = async (modelToProcess) => {
|
134
|
+
hydratedModels[modelToProcess.id] = modelToProcess;
|
135
|
+
|
136
|
+
for (const [name, property] of Object.entries(modelToProcess)) {
|
137
|
+
if (Type.Model.isDryModel(property)) {
|
138
|
+
modelToProcess[name] = await hydrateSubModel(property, modelToProcess, name);
|
139
|
+
} else if (Array.isArray(property) && Type.Model.isDryModel(property[0])) {
|
140
|
+
modelToProcess[name] = await hydrateModelList(property, modelToProcess, name);
|
141
|
+
}
|
142
|
+
}
|
143
|
+
|
144
|
+
return modelToProcess;
|
145
|
+
}
|
146
|
+
|
147
|
+
const hydrateSubModel = async (property, modelToProcess, name) => {
|
148
|
+
if (hydratedModels[property.id]) {
|
149
|
+
return hydratedModels[property.id];
|
150
|
+
}
|
151
|
+
|
152
|
+
const subModelClass = getSubModelClass(modelToProcess, name);
|
153
|
+
const subModel = await this.get(subModelClass, property.id);
|
154
|
+
|
155
|
+
const hydratedSubModel = await hydrateModel(subModel);
|
156
|
+
hydratedModels[property.id] = hydratedSubModel;
|
157
|
+
return hydratedSubModel;
|
158
|
+
}
|
159
|
+
|
160
|
+
const hydrateModelList = async (property, modelToProcess, name) => {
|
161
|
+
const subModelClass = getSubModelClass(modelToProcess, name, true);
|
162
|
+
|
163
|
+
const newModelList = await Promise.all(property.map(async subModel => {
|
164
|
+
if (hydratedModels[subModel.id]) {
|
165
|
+
return hydratedModels[subModel.id];
|
166
|
+
}
|
167
|
+
|
168
|
+
return await this.get(subModelClass, subModel.id);
|
169
|
+
}));
|
170
|
+
|
171
|
+
return await Promise.all(newModelList.map(async subModel => {
|
172
|
+
if (hydratedModels[subModel.id]) {
|
173
|
+
return hydratedModels[subModel.id];
|
174
|
+
}
|
175
|
+
|
176
|
+
const hydratedSubModel = await hydrateModel(subModel);
|
177
|
+
hydratedModels[hydratedSubModel.id] = hydratedSubModel;
|
178
|
+
return hydratedSubModel;
|
179
|
+
}));
|
180
|
+
}
|
181
|
+
|
182
|
+
function getSubModelClass(modelToProcess, name, isArray = false) {
|
183
|
+
const constructorField = modelToProcess.constructor[name];
|
184
|
+
if (constructorField instanceof Function && !constructorField.prototype) {
|
185
|
+
return isArray ? constructorField()._items : constructorField();
|
186
|
+
}
|
187
|
+
return isArray ? constructorField._items : constructorField;
|
188
|
+
}
|
189
|
+
|
190
|
+
return await hydrateModel(await this.get(model.constructor, model.id));
|
191
|
+
}
|
192
|
+
|
193
|
+
static configure(configuration) {
|
194
|
+
class ConfiguredStore extends this {
|
195
|
+
static _configuration = configuration;
|
196
|
+
}
|
197
|
+
|
198
|
+
Object.defineProperty(ConfiguredStore, 'name', {value: `${this.toString()}`})
|
199
|
+
|
200
|
+
return ConfiguredStore;
|
201
|
+
}
|
202
|
+
|
203
|
+
static toString() {
|
204
|
+
return this.name;
|
205
|
+
}
|
206
|
+
};
|
207
|
+
|
208
|
+
export class EngineError extends Error {
|
209
|
+
}
|
210
|
+
|
211
|
+
export class NotFoundEngineError extends EngineError {
|
212
|
+
}
|
213
|
+
|
214
|
+
export class NotImplementedError extends EngineError {
|
215
|
+
}
|
@@ -0,0 +1,79 @@
|
|
1
|
+
import {dirname, join} from 'node:path';
|
2
|
+
import Engine from './Engine.js';
|
3
|
+
import fs from 'node:fs/promises';
|
4
|
+
|
5
|
+
/**
|
6
|
+
* @class FileEngine
|
7
|
+
* @extends Engine
|
8
|
+
*/
|
9
|
+
export default class FileEngine extends Engine {
|
10
|
+
static configure(configuration) {
|
11
|
+
if (!configuration.filesystem) {
|
12
|
+
configuration.filesystem = fs;
|
13
|
+
}
|
14
|
+
return super.configure(configuration);
|
15
|
+
}
|
16
|
+
|
17
|
+
static async getById(id) {
|
18
|
+
const filePath = join(this._configuration.path, `${id}.json`);
|
19
|
+
|
20
|
+
try {
|
21
|
+
return JSON.parse(await this._configuration.filesystem.readFile(filePath).then(f => f.toString()));
|
22
|
+
} catch (_) {
|
23
|
+
return null;
|
24
|
+
}
|
25
|
+
}
|
26
|
+
|
27
|
+
static async findByValue(model, parameters) {
|
28
|
+
const index = JSON.parse((await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_index.json')).catch(() => '{}')).toString());
|
29
|
+
return Object.values(index)
|
30
|
+
.filter((model) =>
|
31
|
+
Object.entries(parameters)
|
32
|
+
.some(([name, value]) => model[name] === value),
|
33
|
+
);
|
34
|
+
}
|
35
|
+
|
36
|
+
static async putModel(model) {
|
37
|
+
const filePath = join(this._configuration.path, `${model.id}.json`);
|
38
|
+
|
39
|
+
await this._configuration.filesystem.mkdir(dirname(filePath), {recursive: true});
|
40
|
+
await this._configuration.filesystem.writeFile(filePath, JSON.stringify(model.toData()));
|
41
|
+
}
|
42
|
+
|
43
|
+
static async putIndex(index) {
|
44
|
+
const processIndex = async (location, models) => {
|
45
|
+
const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()]));
|
46
|
+
const filePath = join(this._configuration.path, location, '_index.json');
|
47
|
+
const currentIndex = JSON.parse((await this._configuration.filesystem.readFile(filePath).catch(() => '{}')).toString());
|
48
|
+
|
49
|
+
await this._configuration.filesystem.writeFile(filePath, JSON.stringify({
|
50
|
+
...currentIndex,
|
51
|
+
...modelIndex,
|
52
|
+
}));
|
53
|
+
};
|
54
|
+
|
55
|
+
for (const [location, models] of Object.entries(index)) {
|
56
|
+
await processIndex(location, models);
|
57
|
+
}
|
58
|
+
|
59
|
+
await processIndex('', Object.values(index).flat());
|
60
|
+
}
|
61
|
+
|
62
|
+
static async getSearchIndexCompiled(model) {
|
63
|
+
return JSON.parse((await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_search_index.json')).catch(() => '{}')).toString());
|
64
|
+
}
|
65
|
+
|
66
|
+
static async getSearchIndexRaw(model) {
|
67
|
+
return JSON.parse((await this._configuration.filesystem.readFile(join(this._configuration.path, model.name, '_search_index_raw.json')).catch(() => '{}')).toString());
|
68
|
+
}
|
69
|
+
|
70
|
+
static async putSearchIndexCompiled(model, compiledIndex) {
|
71
|
+
const filePath = join(this._configuration.path, model.name, '_search_index.json');
|
72
|
+
await this._configuration.filesystem.writeFile(filePath, JSON.stringify(compiledIndex));
|
73
|
+
}
|
74
|
+
|
75
|
+
static async putSearchIndexRaw(model, rawIndex) {
|
76
|
+
const filePath = join(this._configuration.path, model.name, '_search_index_raw.json');
|
77
|
+
await this._configuration.filesystem.writeFile(filePath, JSON.stringify(rawIndex));
|
78
|
+
}
|
79
|
+
}
|
@@ -0,0 +1,124 @@
|
|
1
|
+
import {GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3';
|
2
|
+
import Engine from './Engine.js';
|
3
|
+
|
4
|
+
export default class S3Engine extends Engine {
|
5
|
+
static async getById(id) {
|
6
|
+
const objectPath = [this._configuration.prefix, `${id}.json`].join('/');
|
7
|
+
|
8
|
+
try {
|
9
|
+
const data = await this._configuration.client.send(new GetObjectCommand({
|
10
|
+
Bucket: this._configuration.bucket,
|
11
|
+
Key: objectPath,
|
12
|
+
}));
|
13
|
+
return JSON.parse(await data.Body.transformToString());
|
14
|
+
} catch (_error) {
|
15
|
+
return undefined;
|
16
|
+
}
|
17
|
+
}
|
18
|
+
|
19
|
+
static async findByValue(model, parameters) {
|
20
|
+
const index = await this.getIndex(model.name);
|
21
|
+
return Object.values(index)
|
22
|
+
.filter((model) =>
|
23
|
+
Object.entries(parameters)
|
24
|
+
.some(([name, value]) => model[name] === value),
|
25
|
+
);
|
26
|
+
}
|
27
|
+
|
28
|
+
static async putModel(model) {
|
29
|
+
const Key = [this._configuration.prefix, `${model.id}.json`].join('/');
|
30
|
+
|
31
|
+
await this._configuration.client.send(new PutObjectCommand({
|
32
|
+
Key,
|
33
|
+
Body: JSON.stringify(model.toData()),
|
34
|
+
Bucket: this._configuration.bucket,
|
35
|
+
ContentType: 'application/json',
|
36
|
+
}));
|
37
|
+
}
|
38
|
+
|
39
|
+
static async getIndex(location) {
|
40
|
+
try {
|
41
|
+
const data = await this._configuration.client.send(new GetObjectCommand({
|
42
|
+
Key: [this._configuration.prefix].concat([location]).concat(['_index.json']).filter(e => !!e).join('/'),
|
43
|
+
Bucket: this._configuration.bucket,
|
44
|
+
}));
|
45
|
+
|
46
|
+
return JSON.parse(await data.Body.transformToString());
|
47
|
+
} catch (_error) {
|
48
|
+
return {};
|
49
|
+
}
|
50
|
+
}
|
51
|
+
|
52
|
+
static async putIndex(index) {
|
53
|
+
const processIndex = async (location, models) => {
|
54
|
+
const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()]));
|
55
|
+
const Key = [this._configuration.prefix].concat([location]).concat(['_index.json']).filter(e => !!e).join('/');
|
56
|
+
|
57
|
+
const currentIndex = await this.getIndex(location);
|
58
|
+
|
59
|
+
await this._configuration.client.send(new PutObjectCommand({
|
60
|
+
Key,
|
61
|
+
Bucket: this._configuration.bucket,
|
62
|
+
ContentType: 'application/json',
|
63
|
+
Body: JSON.stringify({
|
64
|
+
...currentIndex,
|
65
|
+
...modelIndex,
|
66
|
+
}),
|
67
|
+
}));
|
68
|
+
};
|
69
|
+
|
70
|
+
for (const [location, models] of Object.entries(index)) {
|
71
|
+
await processIndex(location, models);
|
72
|
+
}
|
73
|
+
|
74
|
+
await processIndex(null, Object.values(index).flat());
|
75
|
+
}
|
76
|
+
|
77
|
+
static async getSearchIndexCompiled(model) {
|
78
|
+
try {
|
79
|
+
const data = await this._configuration.client.send(new GetObjectCommand({
|
80
|
+
Key: [this._configuration.prefix].concat([model.name]).concat(['_search_index.json']).join('/'),
|
81
|
+
Bucket: this._configuration.bucket,
|
82
|
+
}));
|
83
|
+
|
84
|
+
return JSON.parse(await data.Body.transformToString());
|
85
|
+
} catch (_error) {
|
86
|
+
return {};
|
87
|
+
}
|
88
|
+
}
|
89
|
+
|
90
|
+
static async getSearchIndexRaw(model) {
|
91
|
+
try {
|
92
|
+
const data = await this._configuration.client.send(new GetObjectCommand({
|
93
|
+
Key: [this._configuration.prefix].concat([model.name]).concat(['_search_index_raw.json']).join('/'),
|
94
|
+
Bucket: this._configuration.bucket,
|
95
|
+
}));
|
96
|
+
|
97
|
+
return JSON.parse(await data.Body.transformToString());
|
98
|
+
} catch (_error) {
|
99
|
+
return {};
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
static async putSearchIndexCompiled(model, compiledIndex) {
|
104
|
+
const Key = [this._configuration.prefix, model.name, '_search_index.json'].join('/');
|
105
|
+
|
106
|
+
await this._configuration.client.send(new PutObjectCommand({
|
107
|
+
Key,
|
108
|
+
Body: JSON.stringify(compiledIndex),
|
109
|
+
Bucket: this._configuration.bucket,
|
110
|
+
ContentType: 'application/json',
|
111
|
+
}));
|
112
|
+
}
|
113
|
+
|
114
|
+
static async putSearchIndexRaw(model, rawIndex) {
|
115
|
+
const Key = [this._configuration.prefix, model.name, '_search_index_raw.json'].join('/');
|
116
|
+
|
117
|
+
await this._configuration.client.send(new PutObjectCommand({
|
118
|
+
Key,
|
119
|
+
Body: JSON.stringify(rawIndex),
|
120
|
+
Bucket: this._configuration.bucket,
|
121
|
+
ContentType: 'application/json',
|
122
|
+
}));
|
123
|
+
}
|
124
|
+
}
|
@@ -0,0 +1,115 @@
|
|
1
|
+
import SchemaCompiler from '../SchemaCompiler.js';
|
2
|
+
import StringType from './simple/StringType.js';
|
3
|
+
import {monotonicFactory} from 'ulid';
|
4
|
+
|
5
|
+
const createID = monotonicFactory();
|
6
|
+
|
7
|
+
export default class Model {
|
8
|
+
static id = StringType.required;
|
9
|
+
static _required = false;
|
10
|
+
|
11
|
+
constructor(data = {}) {
|
12
|
+
this.id = `${this.constructor.name}/${createID()}`;
|
13
|
+
|
14
|
+
for (const [key, value] of Object.entries(this.constructor)) {
|
15
|
+
if (data?.[key] !== undefined) {
|
16
|
+
this[key] = data[key];
|
17
|
+
}
|
18
|
+
if (value?._resolved) {
|
19
|
+
Object.defineProperty(this, key, {
|
20
|
+
get: function () {
|
21
|
+
return value.resolve(this);
|
22
|
+
},
|
23
|
+
});
|
24
|
+
}
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
toData() {
|
29
|
+
const model = {...this};
|
30
|
+
|
31
|
+
for (const [name, property] of Object.entries(this.constructor)) {
|
32
|
+
if (property._resolved) {
|
33
|
+
model[name] = property.resolve(this);
|
34
|
+
}
|
35
|
+
}
|
36
|
+
|
37
|
+
return JSON.parse(JSON.stringify(model, (key, value) => {
|
38
|
+
if (key && this.constructor.isModel(value)) {
|
39
|
+
return {id: value.id};
|
40
|
+
}
|
41
|
+
return value;
|
42
|
+
}));
|
43
|
+
}
|
44
|
+
|
45
|
+
validate() {
|
46
|
+
return SchemaCompiler.compile(this.constructor).validate(this);
|
47
|
+
}
|
48
|
+
|
49
|
+
toIndexData() {
|
50
|
+
const indexData = {id: this.id};
|
51
|
+
|
52
|
+
for (const name of this.constructor.indexedProperties()) {
|
53
|
+
indexData[name] = this[name];
|
54
|
+
}
|
55
|
+
|
56
|
+
return indexData;
|
57
|
+
}
|
58
|
+
|
59
|
+
toSearchData() {
|
60
|
+
const indexData = {id: this.id};
|
61
|
+
|
62
|
+
for (const name of this.constructor.searchProperties()) {
|
63
|
+
indexData[name] = this[name];
|
64
|
+
}
|
65
|
+
|
66
|
+
return indexData;
|
67
|
+
}
|
68
|
+
|
69
|
+
static toString() {
|
70
|
+
return this['name'];
|
71
|
+
}
|
72
|
+
|
73
|
+
static get required() {
|
74
|
+
class Required extends this {
|
75
|
+
static _required = true;
|
76
|
+
}
|
77
|
+
|
78
|
+
Object.defineProperty(Required, 'name', {value: `${this.toString()}`})
|
79
|
+
|
80
|
+
return Required;
|
81
|
+
}
|
82
|
+
|
83
|
+
static indexedProperties() {
|
84
|
+
return [];
|
85
|
+
}
|
86
|
+
|
87
|
+
static searchProperties() {
|
88
|
+
return [];
|
89
|
+
}
|
90
|
+
|
91
|
+
static fromData(data) {
|
92
|
+
const model = new this();
|
93
|
+
|
94
|
+
for (const [name, value] of Object.entries(data)) {
|
95
|
+
if (this[name]?._resolved) continue;
|
96
|
+
model[name] = value;
|
97
|
+
}
|
98
|
+
|
99
|
+
return model;
|
100
|
+
}
|
101
|
+
|
102
|
+
static isModel(possibleModel) {
|
103
|
+
return (
|
104
|
+
possibleModel?.prototype instanceof Model ||
|
105
|
+
possibleModel?.constructor?.prototype instanceof Model
|
106
|
+
);
|
107
|
+
}
|
108
|
+
|
109
|
+
static isDryModel(possibleDryModel) {
|
110
|
+
return (
|
111
|
+
Object.keys(possibleDryModel).includes('id') &&
|
112
|
+
!!possibleDryModel.id.match(/[A-Za-z]+\/[A-Z0-9]+/)
|
113
|
+
);
|
114
|
+
}
|
115
|
+
}
|
package/src/type/Type.js
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
/**
|
2
|
+
* @class Type
|
3
|
+
* @property {string} _type
|
4
|
+
* @property {boolean} _required
|
5
|
+
* @property {boolean} _resolved
|
6
|
+
* @property {map?} _properties
|
7
|
+
* @property {map?} _items
|
8
|
+
* @property {map?} _schema
|
9
|
+
*/
|
10
|
+
export default class Type {
|
11
|
+
static _required = false;
|
12
|
+
static _resolved = false;
|
13
|
+
static _properties = undefined;
|
14
|
+
static _items = undefined;
|
15
|
+
static _schema = undefined;
|
16
|
+
|
17
|
+
static toString() {
|
18
|
+
return this.name?.replace(/Type$/, '');
|
19
|
+
}
|
20
|
+
|
21
|
+
/**
|
22
|
+
* @return {Type}
|
23
|
+
*/
|
24
|
+
static get required() {
|
25
|
+
class Required extends this {
|
26
|
+
static _required = true;
|
27
|
+
}
|
28
|
+
|
29
|
+
Object.defineProperty(Required, 'name', {value: `Required${this.toString()}Type`});
|
30
|
+
|
31
|
+
return Required;
|
32
|
+
}
|
33
|
+
}
|
@@ -0,0 +1,30 @@
|
|
1
|
+
import Type from '../Type.js';
|
2
|
+
|
3
|
+
export default class ArrayType {
|
4
|
+
static of(type) {
|
5
|
+
class ArrayOf extends Type {
|
6
|
+
static _type = 'array'
|
7
|
+
static _items = type;
|
8
|
+
|
9
|
+
static toString() {
|
10
|
+
return `ArrayOf(${type})`;
|
11
|
+
}
|
12
|
+
|
13
|
+
static get required() {
|
14
|
+
class Required extends this {
|
15
|
+
static _required = true;
|
16
|
+
|
17
|
+
static toString() {
|
18
|
+
return `RequiredArrayOf(${type})`;
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
Object.defineProperty(Required, 'name', {value: `Required${this.toString()}Type`})
|
23
|
+
|
24
|
+
return Required;
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
return ArrayOf;
|
29
|
+
}
|
30
|
+
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
import Type from '../Type.js';
|
2
|
+
import ajv from 'ajv';
|
3
|
+
|
4
|
+
export default class CustomType {
|
5
|
+
static of(schema) {
|
6
|
+
new ajv().compile(schema);
|
7
|
+
|
8
|
+
class Custom extends Type {
|
9
|
+
static _type = 'object';
|
10
|
+
static _schema = schema;
|
11
|
+
}
|
12
|
+
|
13
|
+
return Custom;
|
14
|
+
}
|
15
|
+
}
|
@@ -0,0 +1,19 @@
|
|
1
|
+
import ArrayType from './complex/ArrayType.js';
|
2
|
+
import BooleanType from './simple/BooleanType.js';
|
3
|
+
import CustomType from './complex/CustomType.js';
|
4
|
+
import Model from './Model.js';
|
5
|
+
import NumberType from './simple/NumberType.js';
|
6
|
+
import SlugType from './resolved/SlugType.js';
|
7
|
+
import StringType from './simple/StringType.js';
|
8
|
+
|
9
|
+
const Type = {};
|
10
|
+
|
11
|
+
Type.String = StringType;
|
12
|
+
Type.Number = NumberType;
|
13
|
+
Type.Boolean = BooleanType;
|
14
|
+
Type.Array = ArrayType;
|
15
|
+
Type.Custom = CustomType;
|
16
|
+
Type.Resolved = {Slug: SlugType};
|
17
|
+
Type.Model = Model;
|
18
|
+
|
19
|
+
export default Type;
|
@@ -0,0 +1,19 @@
|
|
1
|
+
import Type from '../Type.js';
|
2
|
+
|
3
|
+
export default class ResolvedType extends Type {
|
4
|
+
static _resolved = true;
|
5
|
+
|
6
|
+
static resolve(_model) {
|
7
|
+
throw new Error(`${this.name} does not implement resolve(model)`);
|
8
|
+
}
|
9
|
+
|
10
|
+
static of(property) {
|
11
|
+
class ResolvedTypeOf extends ResolvedType {
|
12
|
+
static toString() {
|
13
|
+
return `ResolvedTypeOf(${property})`;
|
14
|
+
}
|
15
|
+
}
|
16
|
+
|
17
|
+
return ResolvedTypeOf;
|
18
|
+
}
|
19
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import ResolvedType from './ResolvedType.js';
|
2
|
+
import slugify from 'slugify';
|
3
|
+
|
4
|
+
export default class SlugType extends ResolvedType {
|
5
|
+
static of(property) {
|
6
|
+
class SlugOf extends ResolvedType {
|
7
|
+
static _type = 'string'
|
8
|
+
|
9
|
+
static toString() {
|
10
|
+
return `SlugOf(${property})`;
|
11
|
+
}
|
12
|
+
|
13
|
+
static resolve(model) {
|
14
|
+
if (typeof model?.[property] !== 'string') return '';
|
15
|
+
|
16
|
+
return slugify(model?.[property], {
|
17
|
+
lower: true,
|
18
|
+
});
|
19
|
+
}
|
20
|
+
}
|
21
|
+
|
22
|
+
return SlugOf;
|
23
|
+
}
|
24
|
+
}
|