@acodeninja/persist 3.0.0-next.9 → 3.0.1-next.1

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.
Files changed (41) hide show
  1. package/README.md +60 -4
  2. package/docs/code-quirks.md +14 -14
  3. package/docs/defining-models.md +61 -0
  4. package/docs/http.openapi.yml +138 -0
  5. package/docs/{model-property-types.md → model-properties.md} +76 -43
  6. package/docs/models-as-properties.md +46 -46
  7. package/docs/search-queries.md +11 -13
  8. package/docs/storage-engines.md +19 -35
  9. package/docs/structured-queries.md +59 -48
  10. package/docs/transactions.md +6 -7
  11. package/exports/storage/http.js +3 -0
  12. package/exports/storage/s3.js +3 -0
  13. package/jest.config.cjs +8 -12
  14. package/package.json +2 -2
  15. package/src/Connection.js +750 -0
  16. package/src/Persist.js +29 -30
  17. package/src/Schema.js +175 -0
  18. package/src/{Query.js → data/FindIndex.js} +40 -24
  19. package/src/{type → data}/Model.js +95 -55
  20. package/src/data/Property.js +21 -0
  21. package/src/data/SearchIndex.js +106 -0
  22. package/src/{type/complex → data/properties}/ArrayType.js +5 -3
  23. package/src/{type/simple → data/properties}/BooleanType.js +3 -3
  24. package/src/{type/complex → data/properties}/CustomType.js +5 -5
  25. package/src/{type/simple → data/properties}/DateType.js +4 -4
  26. package/src/{type/simple → data/properties}/NumberType.js +3 -3
  27. package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
  28. package/src/{type/resolved → data/properties}/SlugType.js +1 -1
  29. package/src/{type/simple → data/properties}/StringType.js +3 -3
  30. package/src/{type → data/properties}/Type.js +13 -3
  31. package/src/engine/storage/HTTPStorageEngine.js +149 -253
  32. package/src/engine/storage/S3StorageEngine.js +108 -195
  33. package/src/engine/storage/StorageEngine.js +131 -549
  34. package/exports/engine/storage/file.js +0 -3
  35. package/exports/engine/storage/http.js +0 -3
  36. package/exports/engine/storage/s3.js +0 -3
  37. package/src/SchemaCompiler.js +0 -196
  38. package/src/Transactions.js +0 -145
  39. package/src/engine/StorageEngine.js +0 -472
  40. package/src/engine/storage/FileStorageEngine.js +0 -213
  41. package/src/type/index.js +0 -32
package/src/Persist.js CHANGED
@@ -1,45 +1,44 @@
1
- import Type from './type/index.js';
2
- import enableTransactions from './Transactions.js';
1
+ import Connection from './Connection.js';
2
+ import Model from './data/Model.js';
3
+ import Property from './data/Property.js';
4
+ import {ValidationError} from './Schema.js';
3
5
 
4
6
  /**
5
7
  * @class Persist
6
8
  */
7
9
  class Persist {
8
- static _engine = {};
9
- /**
10
- * @memberof Persist
11
- * @type {Type}
12
- * @static
13
- */
14
- static Type = Type;
10
+ static Property = Property;
11
+ static Model = Model;
12
+ static Connection = Connection;
13
+
14
+ static Errors = {
15
+ ValidationError,
16
+ };
17
+
18
+ static #connections = new Map();
15
19
 
16
20
  /**
17
- * @function getEngine
18
- * @memberof Persist
19
- * @static
20
- * @param {string} group - Name of the group containing the engine
21
- * @param {Engine} engine - The engine class you wish to retrieve
22
- * @return {Engine|null}
21
+ * Register a new connection.
22
+ * @param {string} name
23
+ * @param {StorageEngine} storage
24
+ * @param {Array<Model.constructor>} models
25
+ * @return {Connection}
23
26
  */
24
- static getEngine(group, engine) {
25
- return this._engine[group]?.[engine.name] ?? null;
27
+ static registerConnection(name, storage, models) {
28
+ const connection = new Connection(storage, models);
29
+
30
+ Persist.#connections.set(name, connection);
31
+
32
+ return connection;
26
33
  }
27
34
 
28
35
  /**
29
- * @function addEngine
30
- * @memberof Persist
31
- * @static
32
- * @param {string} group - Name of the group containing the engine
33
- * @param {Engine} engine - The engine class you wish to configure and add to the group
34
- * @param {object?} configuration - The configuration to use with the engine
36
+ * Get a persist connection by its name.
37
+ * @param {string} name
38
+ * @return {Connection|undefined}
35
39
  */
36
- static addEngine(group, engine, configuration) {
37
- if (!this._engine[group]) this._engine[group] = {};
38
-
39
- this._engine[group][engine.name] =
40
- configuration.transactions ?
41
- enableTransactions(engine.configure(configuration)) :
42
- engine.configure(configuration);
40
+ static getConnection(name) {
41
+ return Persist.#connections.get(name);
43
42
  }
44
43
  }
45
44
 
package/src/Schema.js ADDED
@@ -0,0 +1,175 @@
1
+ import Model from './data/Model.js';
2
+ import ajv from 'ajv';
3
+ import ajvErrors from 'ajv-errors';
4
+ import ajvFormats from 'ajv-formats';
5
+
6
+ /**
7
+ * A class responsible for compiling raw schema definitions into a format that can be validated using the AJV (Another JSON Validator) library.
8
+ */
9
+ class Schema {
10
+ /**
11
+ * Compiles a raw schema into a validation-ready schema, and returns a class that extends `CompiledSchema`.
12
+ *
13
+ * This method converts a given schema into a JSON schema-like format, setting up properties, types, formats, and validation rules.
14
+ * It uses AJV for the validation process and integrates with model types and their specific validation rules.
15
+ *
16
+ * @param {Object|Model} rawSchema - The raw schema or model definition to be compiled.
17
+ * @returns {CompiledSchema} - A compiled schema, ready to validate instances of the model.
18
+ *
19
+ * @example
20
+ * const schemaClass = Schema.compile(MyModelSchema);
21
+ * const isValid = schemaClass.validate(data); // Throws ValidationError if data is invalid.
22
+ */
23
+ static compile(rawSchema) {
24
+ const validation = new ajv({allErrors: true});
25
+
26
+ ajvErrors(validation);
27
+ ajvFormats(validation);
28
+
29
+ /**
30
+ * Recursively builds a JSON-schema-like object from a model or schema segment.
31
+ *
32
+ * Handles both `Model` instances and schema property definitions,
33
+ * including nested models and required property rules.
34
+ *
35
+ * @param {Object|Model|Type} schemaSegment - A model or a property descriptor.
36
+ * @returns {Object} A JSON schema representation of the input segment.
37
+ */
38
+ function BuildSchema(schemaSegment) {
39
+ const thisSchema = {};
40
+
41
+ if (Model.isModel(schemaSegment)) {
42
+ thisSchema.required = [];
43
+ thisSchema.type = 'object';
44
+ thisSchema.additionalProperties = false;
45
+ thisSchema.properties = {
46
+ id: {
47
+ type: 'string',
48
+ pattern: `^${schemaSegment.toString()}/[A-Z0-9]+$`,
49
+ },
50
+ };
51
+
52
+ for (const [name, type] of Object.entries(schemaSegment)) {
53
+ if (['indexedProperties', 'searchProperties'].includes(name)) continue;
54
+
55
+ const property = type instanceof Function && !type.prototype ? type() : type;
56
+
57
+ if (property?._required || property?._items?._type?._required) {
58
+ thisSchema.required.push(name);
59
+ }
60
+
61
+ if (Model.isModel(property)) {
62
+ thisSchema.properties[name] = {
63
+ type: 'object',
64
+ additionalProperties: false,
65
+ required: [],
66
+ properties: {
67
+ id: {
68
+ type: 'string',
69
+ pattern: `^${property.toString()}/[A-Z0-9]+$`,
70
+ },
71
+ },
72
+ };
73
+ continue;
74
+ }
75
+
76
+ thisSchema.properties[name] = BuildSchema(property);
77
+ }
78
+
79
+ return thisSchema;
80
+ }
81
+
82
+ if (schemaSegment._schema) {
83
+ return schemaSegment._schema;
84
+ }
85
+
86
+ thisSchema.type = schemaSegment?._type;
87
+
88
+ if (schemaSegment?._format) {
89
+ thisSchema.format = schemaSegment._format;
90
+ }
91
+
92
+ if (schemaSegment?._items) {
93
+ thisSchema.items = {};
94
+ thisSchema.items.type = schemaSegment._items._type;
95
+ if (schemaSegment._items._format)
96
+ thisSchema.items.format = schemaSegment._items._format;
97
+ }
98
+
99
+ return thisSchema;
100
+ }
101
+
102
+ const builtSchema = BuildSchema(rawSchema);
103
+
104
+ return new CompiledSchema(validation.compile(builtSchema));
105
+ }
106
+ }
107
+
108
+
109
+ /**
110
+ * Represents a compiled schema used for validating data models.
111
+ * This class provides a mechanism to validate data using a precompiled schema and a validator function.
112
+ */
113
+ export class CompiledSchema {
114
+ /**
115
+ * The validator function used to validate data against the schema.
116
+ * @type {?Function}
117
+ * @private
118
+ */
119
+ #validator = null;
120
+
121
+ constructor(validator) {
122
+ this.#validator = validator;
123
+ }
124
+
125
+ /**
126
+ * Validates the given data against the compiled schema.
127
+ *
128
+ * If the data is an instance of a model, it will be converted to a plain object via `toData()` before validation.
129
+ *
130
+ * @param {Object|Model} data - The data or model instance to be validated.
131
+ * @returns {boolean} - Returns `true` if the data is valid according to the schema.
132
+ * @throws {ValidationError} - Throws a `ValidationError` if the data is invalid.
133
+ */
134
+ validate(data) {
135
+ let inputData = structuredClone(data);
136
+
137
+ if (Model.isModel(data)) {
138
+ inputData = data.toData();
139
+ }
140
+
141
+ const valid = this.#validator?.(inputData);
142
+
143
+ if (valid) return valid;
144
+
145
+ throw new ValidationError(inputData, this.#validator.errors);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Represents a validation error that occurs when a model or data fails validation.
151
+ * Extends the built-in JavaScript `Error` class.
152
+ */
153
+ export class ValidationError extends Error {
154
+ /**
155
+ * Creates an instance of `ValidationError`.
156
+ *
157
+ * @param {Object} data - The data that failed validation.
158
+ * @param {Array<Object>} errors - A list of validation errors, each typically containing details about what failed.
159
+ */
160
+ constructor(data, errors) {
161
+ super('Validation failed');
162
+ /**
163
+ * An array of validation errors, containing details about each failed validation.
164
+ * @type {Array<Object>}
165
+ */
166
+ this.errors = errors;
167
+ /**
168
+ * The data that caused the validation error.
169
+ * @type {Object}
170
+ */
171
+ this.data = data;
172
+ }
173
+ }
174
+
175
+ export default Schema;
@@ -26,37 +26,47 @@
26
26
  * }
27
27
  * });
28
28
  */
29
- class Query {
29
+ class FindIndex {
30
30
  /**
31
31
  * The query object that defines the search criteria.
32
32
  * @type {Object}
33
+ * @private
33
34
  */
34
- query;
35
+ #index;
36
+
37
+ /**
38
+ * @type {Model.constructor} - The model class.
39
+ * @private
40
+ */
41
+ #modelConstructor;
35
42
 
36
43
  /**
37
44
  * Constructs a new `Query` instance with the provided query object.
38
45
  *
39
- * @param {Object} query - The structured query object defining the search criteria.
46
+ * @param {Model.constructor} modelConstructor - The model class.
47
+ * @param {Record<string, Model>} index - The index dataset to search through.
40
48
  */
41
- constructor(query) {
42
- this.query = query;
49
+ constructor(modelConstructor, index) {
50
+ this.#index = index;
51
+ this.#modelConstructor = modelConstructor;
43
52
  }
44
53
 
45
54
  /**
46
55
  * Executes the query against a model's index and returns the matching results.
47
56
  *
48
- * @param {Model.constructor} model - The model class that contains the `fromData` method for constructing models from data.
49
- * @param {Object<string, Model>} index - The index dataset to search through.
57
+ * @param {Object} query The structured query.
50
58
  * @returns {Array<Model>} The models that match the query.
51
59
  */
52
- execute(model, index) {
53
- return Object.values(index)
60
+ query(query) {
61
+ const splitQuery = this.#splitQuery(query);
62
+
63
+ return Object.values(this.#index)
54
64
  .filter(m =>
55
- this._splitQuery(this.query)
56
- .map(query => Boolean(this._matchesQuery(m, query)))
65
+ splitQuery
66
+ .map(q => Boolean(this.#matchesQuery(m, q)))
57
67
  .every(c => c),
58
68
  )
59
- .map(m => model.fromData(m));
69
+ .map(m => this.#modelConstructor.fromData(m));
60
70
  }
61
71
 
62
72
  /**
@@ -72,26 +82,32 @@ class Query {
72
82
  * @param {Object} inputQuery - The query to match against.
73
83
  * @returns {boolean} True if the subject matches the query, otherwise false.
74
84
  */
75
- _matchesQuery(subject, inputQuery) {
76
- if (['string', 'number', 'boolean'].includes(typeof inputQuery)) return subject === inputQuery;
85
+ #matchesQuery(subject, inputQuery) {
86
+ if (['string', 'number', 'boolean'].includes(typeof inputQuery))
87
+ return subject === inputQuery;
77
88
 
78
- if (inputQuery?.$is !== undefined && subject === inputQuery.$is) return true;
89
+ if (inputQuery?.$is !== undefined && subject === inputQuery.$is)
90
+ return true;
79
91
 
80
92
  if (inputQuery?.$contains !== undefined) {
81
- if (subject.includes?.(inputQuery.$contains)) return true;
93
+ if (subject.includes?.(inputQuery.$contains))
94
+ return true;
82
95
 
83
- for (const value of subject) {
84
- if (this._matchesQuery(value, inputQuery.$contains)) return true;
85
- }
96
+ if (typeof subject[Symbol.iterator] === 'function')
97
+ for (const value of subject) {
98
+ if (this.#matchesQuery(value, inputQuery.$contains))
99
+ return true;
100
+ }
86
101
  }
87
102
 
88
103
  for (const key of Object.keys(inputQuery)) {
89
104
  if (!['$is', '$contains'].includes(key))
90
- if (this._matchesQuery(subject[key], inputQuery[key])) return true;
105
+ if (this.#matchesQuery(subject[key], inputQuery[key]))
106
+ return true;
91
107
  }
92
108
 
93
109
  return false;
94
- };
110
+ }
95
111
 
96
112
  /**
97
113
  * Recursively splits an object into an array of objects,
@@ -105,14 +121,14 @@ class Query {
105
121
  * @returns {Array<Object>} An array of objects, where each object contains a single key-value pair
106
122
  * from the original query or its nested objects.
107
123
  */
108
- _splitQuery(query) {
124
+ #splitQuery(query) {
109
125
  return Object.entries(query)
110
126
  .flatMap(([key, value]) =>
111
127
  typeof value === 'object' && value !== null && !Array.isArray(value)
112
- ? this._splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
128
+ ? this.#splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
113
129
  : {[key]: value},
114
130
  );
115
131
  }
116
132
  }
117
133
 
118
- export default Query;
134
+ export default FindIndex;
@@ -1,5 +1,5 @@
1
- import SchemaCompiler from '../SchemaCompiler.js';
2
- import StringType from './simple/StringType.js';
1
+ import Schema from '../Schema.js';
2
+ import StringType from './properties/StringType.js';
3
3
  import _ from 'lodash';
4
4
  import {monotonicFactory} from 'ulid';
5
5
 
@@ -51,10 +51,9 @@ class Model {
51
51
  /**
52
52
  * Serializes the model instance into an object, optionally retaining complex types.
53
53
  *
54
- * @param {boolean} [simple=true] - Determines whether to format the output using only JSON serialisable types.
55
54
  * @returns {Object} - A serialized representation of the model.
56
55
  */
57
- toData(simple = true) {
56
+ toData() {
58
57
  const model = {...this};
59
58
 
60
59
  for (const [name, property] of Object.entries(this.constructor)) {
@@ -70,21 +69,6 @@ class Model {
70
69
  }
71
70
  return value;
72
71
  }),
73
- (key, value) => {
74
- if (!simple) {
75
- if (this.constructor[key]) {
76
- if (this.constructor[key].name.endsWith('Date')) {
77
- return new Date(value);
78
- }
79
-
80
- if (this.constructor[key].name.endsWith('ArrayOf(Date)')) {
81
- return value.map(d => new Date(d));
82
- }
83
- }
84
- }
85
-
86
- return value;
87
- },
88
72
  );
89
73
  }
90
74
 
@@ -95,7 +79,7 @@ class Model {
95
79
  * @throws {ValidationError} - Throws this error if validation fails.
96
80
  */
97
81
  validate() {
98
- return SchemaCompiler.compile(this.constructor).validate(this);
82
+ return Schema.compile(this.constructor).validate(this);
99
83
  }
100
84
 
101
85
  /**
@@ -167,11 +151,21 @@ class Model {
167
151
  * @static
168
152
  */
169
153
  static get required() {
170
- class Required extends this {
154
+ const ModelClass = this;
155
+
156
+ /**
157
+ * A subclass of the current model with the `_required` flag set to `true`.
158
+ * Used to indicate that the property is required during validation or schema generation.
159
+ *
160
+ * @class
161
+ * @extends {Model}
162
+ * @private
163
+ */
164
+ class Required extends ModelClass {
171
165
  static _required = true;
172
166
  }
173
167
 
174
- Object.defineProperty(Required, 'name', {value: `${this.toString()}`});
168
+ Object.defineProperty(Required, 'name', {value: ModelClass.name});
175
169
 
176
170
  return Required;
177
171
  }
@@ -179,6 +173,10 @@ class Model {
179
173
  /**
180
174
  * Returns a list of properties that are indexed.
181
175
  *
176
+ * - To link to properties of a model use `<name>`
177
+ * - To link to properties of linked models use `<model>.<name>`
178
+ * - To link to properties of many linked models use `<model>.[*].<name>`
179
+ *
182
180
  * @returns {Array<string>} - The indexed properties.
183
181
  * @abstract
184
182
  * @static
@@ -195,29 +193,23 @@ class Model {
195
193
  * @static
196
194
  */
197
195
  static indexedPropertiesResolved() {
198
- return []
199
- .concat(
200
- Object.entries(this)
201
- .filter(([_, type]) =>
202
- this.isModel(
203
- typeof type === 'function' &&
204
- !/^class/.test(Function.prototype.toString.call(type)) ?
205
- type() : type,
206
- ),
207
- )
208
- .map(([name, _]) => `${name}.id`),
209
- )
210
- .concat(
211
- Object.entries(this)
212
- .filter(([_, 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, _]) => `${name}.[*].id`),
219
- )
220
- .concat(this.indexedProperties());
196
+ const ModelClass = this;
197
+ return [
198
+ ...Object.entries(ModelClass.properties)
199
+ .filter(([name, type]) => !['indexedProperties', 'searchProperties'].includes(name) && !type._type && (ModelClass.isModel(type) || (typeof type === 'function' && ModelClass.isModel(type()))))
200
+ .map(([name, _type]) => `${name}.id`),
201
+ ...Object.entries(ModelClass.properties)
202
+ .filter(([_name, type]) => {
203
+ return !Model.isModel(type) && (
204
+ (type._type === 'array' && ModelClass.isModel(type._items))
205
+ ||
206
+ (!type._type && typeof type === 'function' && ModelClass.isModel(type()._items))
207
+ );
208
+ })
209
+ .map(([name, _type]) => `${name}.[*].id`),
210
+ ...ModelClass.indexedProperties(),
211
+ 'id',
212
+ ];
221
213
  }
222
214
 
223
215
  /**
@@ -239,17 +231,18 @@ class Model {
239
231
  * @static
240
232
  */
241
233
  static fromData(data) {
242
- const model = new this();
234
+ const ModelClass = this;
235
+ const model = new ModelClass();
243
236
 
244
237
  for (const [name, value] of Object.entries(data)) {
245
- if (this[name]?._resolved) continue;
238
+ if (ModelClass[name]?._resolved) continue;
246
239
 
247
- if (this[name].name.endsWith('Date')) {
240
+ if (ModelClass[name]?.name.endsWith('Date')) {
248
241
  model[name] = new Date(value);
249
242
  continue;
250
243
  }
251
244
 
252
- if (this[name].name.endsWith('ArrayOf(Date)')) {
245
+ if (ModelClass[name]?.name.endsWith('ArrayOf(Date)')) {
253
246
  model[name] = data[name].map(d => new Date(d));
254
247
  continue;
255
248
  }
@@ -284,7 +277,7 @@ class Model {
284
277
  static isDryModel(possibleDryModel) {
285
278
  try {
286
279
  return (
287
- !this.isModel(possibleDryModel) &&
280
+ !Model.isModel(possibleDryModel) &&
288
281
  Object.keys(possibleDryModel).includes('id') &&
289
282
  new RegExp(/[A-Za-z]+\/[A-Z0-9]+/).test(possibleDryModel.id)
290
283
  );
@@ -302,15 +295,62 @@ class Model {
302
295
  * @static
303
296
  *
304
297
  * @example
305
- * export default class TestModel {
298
+ * export default class TestModel extends Model {
306
299
  * static {
307
- * this.string = Persist.Type.String;
308
- * Object.defineProperty(this, 'name', {value: 'TestModel'});
300
+ * TestModel.withName('TestModel');
301
+ * TestModel.string = Persist.Property.String;
309
302
  * }
310
303
  * }
311
304
  */
312
- static setMinifiedName(name) {
313
- Object.defineProperty(this, 'name', {value: name});
305
+ static withName(name) {
306
+ const ModelClass = this;
307
+ Object.defineProperty(ModelClass, 'name', {value: name});
308
+ }
309
+
310
+ /**
311
+ * Discover model properties all the way up the prototype chain.
312
+ *
313
+ * @return {Model}
314
+ */
315
+ static get properties() {
316
+ const ModelClass = this;
317
+ const props = {};
318
+ const chain = [];
319
+
320
+ let current = ModelClass;
321
+ while (current !== Function.prototype) {
322
+ chain.push(current);
323
+ current = Object.getPrototypeOf(current);
324
+ }
325
+
326
+ for (const item of chain) {
327
+ for (const property of Object.getOwnPropertyNames(item)) {
328
+ if (
329
+ [
330
+ '_required',
331
+ 'fromData',
332
+ 'indexedProperties',
333
+ 'indexedPropertiesResolved',
334
+ 'isDryModel',
335
+ 'isModel',
336
+ 'length',
337
+ 'name',
338
+ 'properties',
339
+ 'prototype',
340
+ 'required',
341
+ 'searchProperties',
342
+ 'toString',
343
+ 'withName',
344
+ ].includes(property)
345
+ ) continue;
346
+
347
+ if (Object.keys(props).includes(property)) continue;
348
+
349
+ props[property] = item[property];
350
+ }
351
+ }
352
+
353
+ return Object.assign(ModelClass, props);
314
354
  }
315
355
  }
316
356
 
@@ -0,0 +1,21 @@
1
+ import ArrayType from './properties/ArrayType.js';
2
+ import BooleanType from './properties/BooleanType.js';
3
+ import CustomType from './properties/CustomType.js';
4
+ import DateType from './properties/DateType.js';
5
+ import NumberType from './properties/NumberType.js';
6
+ import SlugType from './properties/SlugType.js';
7
+ import StringType from './properties/StringType.js';
8
+ import Type from './properties/Type.js';
9
+
10
+ const Property = {
11
+ Array: ArrayType,
12
+ Boolean: BooleanType,
13
+ Custom: CustomType,
14
+ Date: DateType,
15
+ Number: NumberType,
16
+ Slug: SlugType,
17
+ String: StringType,
18
+ Type,
19
+ };
20
+
21
+ export default Property;