@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 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
+ ```
@@ -0,0 +1,3 @@
1
+ import Persist from '../src/Persist.js';
2
+
3
+ export default Persist;
@@ -0,0 +1,3 @@
1
+ import FileEngine from '../../src/engine/FileEngine.js';
2
+
3
+ export default FileEngine;
@@ -0,0 +1,3 @@
1
+ import S3Engine from '../../src/engine/S3Engine.js';
2
+
3
+ export default S3Engine;
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
+ }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import SimpleType from './SimpleType.js';
2
+
3
+ export default class BooleanType extends SimpleType {
4
+ static _type = 'boolean';
5
+ }
@@ -0,0 +1,5 @@
1
+ import SimpleType from './SimpleType.js';
2
+
3
+ export default class NumberType extends SimpleType {
4
+ static _type = 'number';
5
+ }
@@ -0,0 +1,4 @@
1
+ import Type from '../Type.js';
2
+
3
+ export default class SimpleType extends Type {
4
+ }
@@ -0,0 +1,5 @@
1
+ import SimpleType from './SimpleType.js';
2
+
3
+ export default class StringType extends SimpleType {
4
+ static _type = 'string';
5
+ }