@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.
- package/README.md +58 -4
- package/docs/code-quirks.md +6 -6
- package/docs/defining-models.md +61 -0
- package/docs/future-interface.md +29 -0
- package/docs/http.openapi.yml +138 -0
- package/docs/{model-property-types.md → model-properties.md} +37 -35
- package/docs/models-as-properties.md +40 -40
- package/docs/search-queries.md +3 -5
- package/docs/storage-engines.md +15 -32
- package/docs/structured-queries.md +56 -45
- package/docs/transactions.md +6 -7
- package/exports/storage/http.js +3 -0
- package/exports/storage/s3.js +3 -0
- package/jest.config.cjs +8 -12
- package/package.json +2 -2
- package/src/Connection.js +610 -0
- package/src/Persist.js +27 -30
- package/src/Schema.js +166 -0
- package/src/{Query.js → data/FindIndex.js} +37 -22
- package/src/{type → data}/Model.js +14 -30
- package/src/data/Property.js +19 -0
- package/src/data/SearchIndex.js +19 -4
- package/src/{type/complex → data/properties}/ArrayType.js +1 -1
- package/src/{type/simple → data/properties}/BooleanType.js +1 -1
- package/src/{type/complex → data/properties}/CustomType.js +1 -1
- package/src/{type/simple → data/properties}/DateType.js +1 -1
- package/src/{type/simple → data/properties}/NumberType.js +1 -1
- package/src/{type/resolved → data/properties}/ResolvedType.js +3 -2
- package/src/{type/simple → data/properties}/StringType.js +1 -1
- package/src/engine/storage/HTTPStorageEngine.js +149 -253
- package/src/engine/storage/S3StorageEngine.js +108 -195
- package/src/engine/storage/StorageEngine.js +114 -550
- package/exports/engine/storage/file.js +0 -3
- package/exports/engine/storage/http.js +0 -3
- package/exports/engine/storage/s3.js +0 -3
- package/src/SchemaCompiler.js +0 -196
- package/src/Transactions.js +0 -145
- package/src/engine/StorageEngine.js +0 -517
- package/src/engine/storage/FileStorageEngine.js +0 -213
- package/src/type/index.js +0 -32
- /package/src/{type/resolved → data/properties}/SlugType.js +0 -0
- /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
|
29
|
+
class FindIndex {
|
30
30
|
/**
|
31
31
|
* The query object that defines the search criteria.
|
32
32
|
* @type {Object}
|
33
|
+
* @private
|
33
34
|
*/
|
34
|
-
|
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 {
|
46
|
+
* @param {Model.constructor} constructor - The model class.
|
47
|
+
* @param {Record<string, Model>} index - The index dataset to search through.
|
40
48
|
*/
|
41
|
-
constructor(
|
42
|
-
this
|
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 {
|
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
|
-
|
53
|
-
|
60
|
+
query(query) {
|
61
|
+
const splitQuery = this.#splitQuery(query);
|
62
|
+
|
63
|
+
return Object.values(this.#index)
|
54
64
|
.filter(m =>
|
55
|
-
|
56
|
-
.map(
|
65
|
+
splitQuery
|
66
|
+
.map(q => Boolean(this.#matchesQuery(m, q)))
|
57
67
|
.every(c => c),
|
58
68
|
)
|
59
|
-
.map(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
|
-
|
76
|
-
if (['string', 'number', 'boolean'].includes(typeof 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)
|
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))
|
93
|
+
if (subject.includes?.(inputQuery.$contains))
|
94
|
+
return true;
|
82
95
|
|
83
96
|
for (const value of subject) {
|
84
|
-
if (this
|
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
|
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
|
-
|
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
|
127
|
+
? this.#splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
|
113
128
|
: {[key]: value},
|
114
129
|
);
|
115
130
|
}
|
116
131
|
}
|
117
132
|
|
118
|
-
export default
|
133
|
+
export default FindIndex;
|
@@ -1,5 +1,5 @@
|
|
1
|
-
import
|
2
|
-
import StringType from './
|
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(
|
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
|
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
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
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]
|
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]
|
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
|
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;
|
package/src/data/SearchIndex.js
CHANGED
@@ -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
|
18
|
-
|
19
|
-
|
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 '
|
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
|
50
|
+
return `${that.toString()}Of(${property})`;
|
50
51
|
}
|
51
52
|
}
|
52
53
|
|