@acodeninja/persist 3.0.0-next.2 → 3.0.0-next.20
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 +60 -4
- package/docs/code-quirks.md +6 -6
- package/docs/defining-models.md +61 -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 +19 -35
- 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 +631 -0
- package/src/Persist.js +29 -30
- package/src/Schema.js +175 -0
- package/src/{Query.js → data/FindIndex.js} +40 -24
- package/src/{type → data}/Model.js +41 -26
- package/src/data/Property.js +19 -0
- package/src/data/SearchIndex.js +106 -0
- 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/{type → data/properties}/Type.js +8 -0
- 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 -192
- package/src/Transactions.js +0 -145
- package/src/engine/StorageEngine.js +0 -250
- 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/Persist.js
CHANGED
@@ -1,45 +1,44 @@
|
|
1
|
-
import
|
2
|
-
import
|
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
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
*
|
18
|
-
* @
|
19
|
-
* @
|
20
|
-
* @param {
|
21
|
-
* @
|
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
|
25
|
-
|
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
|
-
*
|
30
|
-
* @
|
31
|
-
* @
|
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
|
37
|
-
|
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 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
|
+
/**
|
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 = ['id'];
|
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: ['id'],
|
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
|
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} modelConstructor - The model class.
|
47
|
+
* @param {Record<string, Model>} index - The index dataset to search through.
|
40
48
|
*/
|
41
|
-
constructor(
|
42
|
-
this
|
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 {
|
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,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
|
-
|
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
|
-
|
84
|
-
|
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
|
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
|
-
|
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
|
128
|
+
? this.#splitQuery(value).map(nestedObj => ({[key]: nestedObj}))
|
113
129
|
: {[key]: value},
|
114
130
|
);
|
115
131
|
}
|
116
132
|
}
|
117
133
|
|
118
|
-
export default
|
134
|
+
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,16 +79,16 @@ 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
|
/**
|
102
86
|
* Extracts data from the model based on the indexed properties defined in the class.
|
103
|
-
*
|
87
|
+
* Includes the ID of any linked models.
|
104
88
|
* @returns {Object} - A representation of the model's indexed data.
|
105
89
|
*/
|
106
90
|
toIndexData() {
|
107
|
-
return this._extractData(this.constructor.
|
91
|
+
return this._extractData(this.constructor.indexedPropertiesResolved());
|
108
92
|
}
|
109
93
|
|
110
94
|
/**
|
@@ -167,6 +151,14 @@ class Model {
|
|
167
151
|
* @static
|
168
152
|
*/
|
169
153
|
static get required() {
|
154
|
+
/**
|
155
|
+
* A subclass of the current model with the `_required` flag set to `true`.
|
156
|
+
* Used to indicate that the property is required during validation or schema generation.
|
157
|
+
*
|
158
|
+
* @class
|
159
|
+
* @extends {Model}
|
160
|
+
* @private
|
161
|
+
*/
|
170
162
|
class Required extends this {
|
171
163
|
static _required = true;
|
172
164
|
}
|
@@ -179,6 +171,10 @@ class Model {
|
|
179
171
|
/**
|
180
172
|
* Returns a list of properties that are indexed.
|
181
173
|
*
|
174
|
+
* - To link to properties of a model use `<name>`
|
175
|
+
* - To link to properties of linked models use `<model>.<name>`
|
176
|
+
* - To link to properties of many linked models use `<model>.[*].<name>`
|
177
|
+
*
|
182
178
|
* @returns {Array<string>} - The indexed properties.
|
183
179
|
* @abstract
|
184
180
|
* @static
|
@@ -187,6 +183,25 @@ class Model {
|
|
187
183
|
return [];
|
188
184
|
}
|
189
185
|
|
186
|
+
/**
|
187
|
+
* Returns a list of properties that are indexed including links to other models.
|
188
|
+
*
|
189
|
+
* @returns {Array<string>} - The indexed properties.
|
190
|
+
* @abstract
|
191
|
+
* @static
|
192
|
+
*/
|
193
|
+
static indexedPropertiesResolved() {
|
194
|
+
return [
|
195
|
+
...Object.entries(this)
|
196
|
+
.filter(([_name, type]) => !type._type && (this.isModel(type) || this.isModel(type())))
|
197
|
+
.map(([name, _type]) => `${name}.id`),
|
198
|
+
...Object.entries(this)
|
199
|
+
.filter(([_name, type]) => !type._type && !this.isModel(type) && !type._items?._type && (this.isModel(type._items) || this.isModel(type()._items)))
|
200
|
+
.map(([name, _type]) => `${name}.[*].id`),
|
201
|
+
...this.indexedProperties(),
|
202
|
+
];
|
203
|
+
}
|
204
|
+
|
190
205
|
/**
|
191
206
|
* Returns a list of properties used for search.
|
192
207
|
*
|
@@ -211,12 +226,12 @@ class Model {
|
|
211
226
|
for (const [name, value] of Object.entries(data)) {
|
212
227
|
if (this[name]?._resolved) continue;
|
213
228
|
|
214
|
-
if (this[name]
|
229
|
+
if (this[name]?.name.endsWith('Date')) {
|
215
230
|
model[name] = new Date(value);
|
216
231
|
continue;
|
217
232
|
}
|
218
233
|
|
219
|
-
if (this[name]
|
234
|
+
if (this[name]?.name.endsWith('ArrayOf(Date)')) {
|
220
235
|
model[name] = data[name].map(d => new Date(d));
|
221
236
|
continue;
|
222
237
|
}
|
@@ -271,12 +286,12 @@ class Model {
|
|
271
286
|
* @example
|
272
287
|
* export default class TestModel {
|
273
288
|
* static {
|
289
|
+
* this.withName('TestModel');
|
274
290
|
* this.string = Persist.Type.String;
|
275
|
-
* Object.defineProperty(this, 'name', {value: 'TestModel'});
|
276
291
|
* }
|
277
292
|
* }
|
278
293
|
*/
|
279
|
-
static
|
294
|
+
static withName(name) {
|
280
295
|
Object.defineProperty(this, 'name', {value: name});
|
281
296
|
}
|
282
297
|
}
|
@@ -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;
|
@@ -0,0 +1,106 @@
|
|
1
|
+
import lunr from 'lunr';
|
2
|
+
|
3
|
+
/**
|
4
|
+
* Represents a single search result with the associated model instance and its relevance score.
|
5
|
+
*
|
6
|
+
* @class SearchResult
|
7
|
+
*/
|
8
|
+
export class SearchResult {
|
9
|
+
constructor(model, score) {
|
10
|
+
this.model = model;
|
11
|
+
this.score = score;
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
/**
|
16
|
+
* A full-text search index wrapper using Lunr.js for a given model.
|
17
|
+
* Supports indexing and querying model data.
|
18
|
+
*
|
19
|
+
* @class SearchIndex
|
20
|
+
*/
|
21
|
+
export default class SearchIndex {
|
22
|
+
#index;
|
23
|
+
#model;
|
24
|
+
#compiledIndex;
|
25
|
+
|
26
|
+
/**
|
27
|
+
* Initializes the search index for the provided model.
|
28
|
+
*
|
29
|
+
* @param {Model} model - The model definition to use for indexing.
|
30
|
+
* @param {Object.<string, Object>} index - A dictionary of model data, keyed by ID.
|
31
|
+
* @throws {NoIndexAvailableSearchIndexError} If the model has no searchable properties.
|
32
|
+
*/
|
33
|
+
constructor(model, index) {
|
34
|
+
this.#index = index;
|
35
|
+
this.#model = model;
|
36
|
+
if (model.searchProperties().length === 0) {
|
37
|
+
throw new NoIndexAvailableSearchIndexError(this.#model);
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
/**
|
42
|
+
* Performs a search query on the compiled Lunr index.
|
43
|
+
*
|
44
|
+
* @param {string} query - The search string.
|
45
|
+
* @return {Array<SearchResult>} An array of search results with model instances and scores.
|
46
|
+
*/
|
47
|
+
search(query) {
|
48
|
+
return this.searchIndex
|
49
|
+
.search(query)
|
50
|
+
.map(doc => new SearchResult(this.#model.fromData(this.#index[doc.ref]), doc.score));
|
51
|
+
}
|
52
|
+
|
53
|
+
/**
|
54
|
+
* Lazily compiles and returns the Lunr index instance.
|
55
|
+
*
|
56
|
+
* @return {lunr.Index} The compiled Lunr index.
|
57
|
+
*/
|
58
|
+
get searchIndex() {
|
59
|
+
return this.#compiledIndex ?? this.#compileIndex();
|
60
|
+
}
|
61
|
+
|
62
|
+
/**
|
63
|
+
* Compiles the Lunr index using the model's search properties.
|
64
|
+
*
|
65
|
+
* @return {lunr.Index} The compiled Lunr index.
|
66
|
+
* @private
|
67
|
+
*/
|
68
|
+
#compileIndex() {
|
69
|
+
const model = this.#model;
|
70
|
+
const index = this.#index;
|
71
|
+
this.#compiledIndex = lunr(function () {
|
72
|
+
this.ref('id');
|
73
|
+
|
74
|
+
for (const field of model.searchProperties()) {
|
75
|
+
this.field(field);
|
76
|
+
}
|
77
|
+
|
78
|
+
Object.values(index).forEach(function (doc) {
|
79
|
+
this.add(doc);
|
80
|
+
}, this);
|
81
|
+
});
|
82
|
+
|
83
|
+
return this.#compiledIndex;
|
84
|
+
}
|
85
|
+
}
|
86
|
+
|
87
|
+
/**
|
88
|
+
* Base error class for search index-related exceptions.
|
89
|
+
*
|
90
|
+
* @class SearchIndexError
|
91
|
+
* @extends {Error}
|
92
|
+
*/
|
93
|
+
export class SearchIndexError extends Error {
|
94
|
+
}
|
95
|
+
|
96
|
+
/**
|
97
|
+
* Thrown when a model does not have any properties defined for indexing.
|
98
|
+
*
|
99
|
+
* @class NoIndexAvailableSearchIndexError
|
100
|
+
* @extends {SearchIndexError}
|
101
|
+
*/
|
102
|
+
export class NoIndexAvailableSearchIndexError extends SearchIndexError {
|
103
|
+
constructor(model) {
|
104
|
+
super(`The model ${model.name} has no search properties`);
|
105
|
+
}
|
106
|
+
}
|