@acodeninja/persist 3.0.0-next.12 → 3.0.0-next.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +58 -4
  2. package/docs/code-quirks.md +6 -6
  3. package/docs/defining-models.md +61 -0
  4. package/docs/future-interface.md +29 -0
  5. package/docs/http.openapi.yml +138 -0
  6. package/docs/{model-property-types.md → model-properties.md} +37 -35
  7. package/docs/models-as-properties.md +40 -40
  8. package/docs/search-queries.md +3 -5
  9. package/docs/storage-engines.md +15 -32
  10. package/docs/structured-queries.md +56 -45
  11. package/docs/transactions.md +6 -7
  12. package/exports/storage/http.js +3 -0
  13. package/exports/storage/s3.js +3 -0
  14. package/jest.config.cjs +8 -12
  15. package/package.json +2 -2
  16. package/src/Connection.js +610 -0
  17. package/src/Persist.js +27 -30
  18. package/src/Schema.js +166 -0
  19. package/src/{Query.js → data/FindIndex.js} +37 -22
  20. package/src/{type → data}/Model.js +14 -30
  21. package/src/data/Property.js +19 -0
  22. package/src/data/SearchIndex.js +19 -4
  23. package/src/{type/complex → data/properties}/ArrayType.js +1 -1
  24. package/src/{type/simple → data/properties}/BooleanType.js +1 -1
  25. package/src/{type/complex → data/properties}/CustomType.js +1 -1
  26. package/src/{type/simple → data/properties}/DateType.js +1 -1
  27. package/src/{type/simple → data/properties}/NumberType.js +1 -1
  28. package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
  29. package/src/{type/simple → data/properties}/StringType.js +1 -1
  30. package/src/engine/storage/HTTPStorageEngine.js +149 -253
  31. package/src/engine/storage/S3StorageEngine.js +108 -195
  32. package/src/engine/storage/StorageEngine.js +114 -550
  33. package/exports/engine/storage/file.js +0 -3
  34. package/exports/engine/storage/http.js +0 -3
  35. package/exports/engine/storage/s3.js +0 -3
  36. package/src/SchemaCompiler.js +0 -196
  37. package/src/Transactions.js +0 -145
  38. package/src/engine/StorageEngine.js +0 -517
  39. package/src/engine/storage/FileStorageEngine.js +0 -213
  40. package/src/type/index.js +0 -32
  41. /package/src/{type/resolved → data/properties}/SlugType.js +0 -0
  42. /package/src/{type → data/properties}/Type.js +0 -0
package/src/Schema.js ADDED
@@ -0,0 +1,166 @@
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 class that extends `CompiledSchema`, with the compiled schema and validator attached.
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
+ function BuildSchema(schemaSegment) {
30
+ const thisSchema = {};
31
+
32
+ if (Model.isModel(schemaSegment)) {
33
+ thisSchema.required = ['id'];
34
+ thisSchema.type = 'object';
35
+ thisSchema.additionalProperties = false;
36
+ thisSchema.properties = {
37
+ id: {
38
+ type: 'string',
39
+ pattern: `^${schemaSegment.toString()}/[A-Z0-9]+$`,
40
+ },
41
+ };
42
+
43
+ for (const [name, type] of Object.entries(schemaSegment)) {
44
+ if (['indexedProperties', 'searchProperties'].includes(name)) continue;
45
+
46
+ const property = type instanceof Function && !type.prototype ? type() : type;
47
+
48
+ if (property?._required || property?._items?._type?._required) {
49
+ thisSchema.required.push(name);
50
+ }
51
+
52
+ if (Model.isModel(property)) {
53
+ thisSchema.properties[name] = {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ required: ['id'],
57
+ properties: {
58
+ id: {
59
+ type: 'string',
60
+ pattern: `^${property.toString()}/[A-Z0-9]+$`,
61
+ },
62
+ },
63
+ };
64
+ continue;
65
+ }
66
+
67
+ thisSchema.properties[name] = BuildSchema(property);
68
+ }
69
+
70
+ return thisSchema;
71
+ }
72
+
73
+ if (schemaSegment._schema) {
74
+ return schemaSegment._schema;
75
+ }
76
+
77
+ thisSchema.type = schemaSegment?._type;
78
+
79
+ if (schemaSegment?._format) {
80
+ thisSchema.format = schemaSegment._format;
81
+ }
82
+
83
+ if (schemaSegment?._items) {
84
+ thisSchema.items = {};
85
+ thisSchema.items.type = schemaSegment._items._type;
86
+ if (schemaSegment._items._format)
87
+ thisSchema.items.format = schemaSegment._items._format;
88
+ }
89
+
90
+ return thisSchema;
91
+ }
92
+
93
+ const builtSchema = BuildSchema(rawSchema);
94
+
95
+ return new CompiledSchema(validation.compile(builtSchema));
96
+ }
97
+ }
98
+
99
+
100
+ /**
101
+ * Represents a compiled schema used for validating data models.
102
+ * This class provides a mechanism to validate data using a precompiled schema and a validator function.
103
+ */
104
+ export class CompiledSchema {
105
+ /**
106
+ * The validator function used to validate data against the schema.
107
+ * @type {?Function}
108
+ * @private
109
+ */
110
+ #validator = null;
111
+
112
+ constructor(validator) {
113
+ this.#validator = validator;
114
+ }
115
+
116
+ /**
117
+ * Validates the given data against the compiled schema.
118
+ *
119
+ * If the data is an instance of a model, it will be converted to a plain object via `toData()` before validation.
120
+ *
121
+ * @param {Object|Model} data - The data or model instance to be validated.
122
+ * @returns {boolean} - Returns `true` if the data is valid according to the schema.
123
+ * @throws {ValidationError} - Throws a `ValidationError` if the data is invalid.
124
+ */
125
+ validate(data) {
126
+ let inputData = structuredClone(data);
127
+
128
+ if (Model.isModel(data)) {
129
+ inputData = data.toData();
130
+ }
131
+
132
+ const valid = this.#validator?.(inputData);
133
+
134
+ if (valid) return valid;
135
+
136
+ throw new ValidationError(inputData, this.#validator.errors);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Represents a validation error that occurs when a model or data fails validation.
142
+ * Extends the built-in JavaScript `Error` class.
143
+ */
144
+ export class ValidationError extends Error {
145
+ /**
146
+ * Creates an instance of `ValidationError`.
147
+ *
148
+ * @param {Object} data - The data that failed validation.
149
+ * @param {Array<Object>} errors - A list of validation errors, each typically containing details about what failed.
150
+ */
151
+ constructor(data, errors) {
152
+ super('Validation failed');
153
+ /**
154
+ * An array of validation errors, containing details about each failed validation.
155
+ * @type {Array<Object>}
156
+ */
157
+ this.errors = errors;
158
+ /**
159
+ * The data that caused the validation error.
160
+ * @type {Object}
161
+ */
162
+ this.data = data;
163
+ }
164
+ }
165
+
166
+ 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} constructor - 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(constructor, index) {
50
+ this.#index = index;
51
+ this.#modelConstructor = constructor;
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,31 @@ 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
96
  for (const value of subject) {
84
- if (this._matchesQuery(value, inputQuery.$contains)) return true;
97
+ if (this.#matchesQuery(value, inputQuery.$contains))
98
+ return true;
85
99
  }
86
100
  }
87
101
 
88
102
  for (const key of Object.keys(inputQuery)) {
89
103
  if (!['$is', '$contains'].includes(key))
90
- if (this._matchesQuery(subject[key], inputQuery[key])) return true;
104
+ if (this.#matchesQuery(subject[key], inputQuery[key]))
105
+ return true;
91
106
  }
92
107
 
93
108
  return false;
94
- };
109
+ }
95
110
 
96
111
  /**
97
112
  * Recursively splits an object into an array of objects,
@@ -105,14 +120,14 @@ class Query {
105
120
  * @returns {Array<Object>} An array of objects, where each object contains a single key-value pair
106
121
  * from the original query or its nested objects.
107
122
  */
108
- _splitQuery(query) {
123
+ #splitQuery(query) {
109
124
  return Object.entries(query)
110
125
  .flatMap(([key, value]) =>
111
126
  typeof value === 'object' && value !== null && !Array.isArray(value)
112
- ? this._splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
127
+ ? this.#splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
113
128
  : {[key]: value},
114
129
  );
115
130
  }
116
131
  }
117
132
 
118
- export default Query;
133
+ 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
  /**
@@ -210,12 +194,12 @@ class Model {
210
194
  .concat(
211
195
  Object.entries(this)
212
196
  .filter(([_name, type]) =>
213
- type?._type === 'array' && this.isModel(
214
- typeof type._items === 'function' &&
215
- !/^class/.test(Function.prototype.toString.call(type._items)) ?
216
- type._items() : type._items,
217
- ),
218
- ).map(([name, _type]) => `${name}.[*].id`),
197
+ !/^class/.test(Function.prototype.toString.call(type)) || type.toString().includes('Array'),
198
+ )
199
+ .filter(([_name, type]) =>
200
+ this.isModel(type._items ?? type()._items ?? type),
201
+ )
202
+ .map(([name, _type]) => `${name}.[*].id`),
219
203
  )
220
204
  .concat(this.indexedProperties());
221
205
  }
@@ -244,12 +228,12 @@ class Model {
244
228
  for (const [name, value] of Object.entries(data)) {
245
229
  if (this[name]?._resolved) continue;
246
230
 
247
- if (this[name].name.endsWith('Date')) {
231
+ if (this[name]?.name.endsWith('Date')) {
248
232
  model[name] = new Date(value);
249
233
  continue;
250
234
  }
251
235
 
252
- if (this[name].name.endsWith('ArrayOf(Date)')) {
236
+ if (this[name]?.name.endsWith('ArrayOf(Date)')) {
253
237
  model[name] = data[name].map(d => new Date(d));
254
238
  continue;
255
239
  }
@@ -304,12 +288,12 @@ class Model {
304
288
  * @example
305
289
  * export default class TestModel {
306
290
  * static {
291
+ * this.withName('TestModel');
307
292
  * this.string = Persist.Type.String;
308
- * Object.defineProperty(this, 'name', {value: 'TestModel'});
309
293
  * }
310
294
  * }
311
295
  */
312
- static setMinifiedName(name) {
296
+ static withName(name) {
313
297
  Object.defineProperty(this, 'name', {value: name});
314
298
  }
315
299
  }
@@ -0,0 +1,19 @@
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
+
9
+ const Property = {
10
+ Array: ArrayType,
11
+ Boolean: BooleanType,
12
+ Custom: CustomType,
13
+ Date: DateType,
14
+ Number: NumberType,
15
+ Slug: SlugType,
16
+ String: StringType,
17
+ };
18
+
19
+ export default Property;
@@ -1,5 +1,12 @@
1
1
  import lunr from 'lunr';
2
2
 
3
+ export class SearchResult {
4
+ constructor(model, score) {
5
+ this.model = model;
6
+ this.score = score;
7
+ }
8
+ }
9
+
3
10
  export default class SearchIndex {
4
11
  #index;
5
12
  #model;
@@ -13,11 +20,19 @@ export default class SearchIndex {
13
20
  }
14
21
  }
15
22
 
23
+ /**
24
+ *
25
+ * @param {string} query
26
+ * @return {Array<SearchResult>}
27
+ */
16
28
  search(query) {
17
- return (this.#compiledIndex ?? this.#compileIndex()).search(query).map(doc => ({
18
- score: doc.score,
19
- model: this.#model.fromData(this.#index[doc.ref]),
20
- }));
29
+ return this.searchIndex
30
+ .search(query)
31
+ .map(doc => new SearchResult(this.#model.fromData(this.#index[doc.ref]), doc.score));
32
+ }
33
+
34
+ get searchIndex() {
35
+ return this.#compiledIndex ?? this.#compileIndex();
21
36
  }
22
37
 
23
38
  #compileIndex() {
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Represents an array type definition, allowing the specification of an array of a certain type.
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Class representing a boolean type.
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
  import ajv from 'ajv';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Class representing a date type with ISO date-time format.
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Class representing a number type.
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Class representing a resolved type.
@@ -39,6 +39,7 @@ class ResolvedType extends Type {
39
39
  * @returns {ResolvedType} A subclass of `ResolvedType` customized for the provided property.
40
40
  */
41
41
  static of(property) {
42
+ const that = this;
42
43
  class ResolvedTypeOf extends ResolvedType {
43
44
  /**
44
45
  * Converts the resolved type to a string, displaying the resolved property.
@@ -46,7 +47,7 @@ class ResolvedType extends Type {
46
47
  * @returns {string} A string representing the resolved type, including the property.
47
48
  */
48
49
  static toString() {
49
- return `ResolvedTypeOf(${property})`;
50
+ return `${that.toString()}Of(${property})`;
50
51
  }
51
52
  }
52
53
 
@@ -1,4 +1,4 @@
1
- import Type from '../Type.js';
1
+ import Type from './Type.js';
2
2
 
3
3
  /**
4
4
  * Class representing a string type.