@acodeninja/persist 2.3.1 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acodeninja/persist",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "A JSON based data modelling and persistence module with alternate storage mechanisms.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/Persist.js CHANGED
@@ -1,4 +1,4 @@
1
- import Type from '../src/type/index.js';
1
+ import Type from './type/index.js';
2
2
  import enableTransactions from './Transactions.js';
3
3
 
4
4
  /**
@@ -24,6 +24,18 @@ class Engine {
24
24
  return Promise.reject(new NotImplementedError(`${this.name} must implement .getById()`));
25
25
  }
26
26
 
27
+ /**
28
+ * Deletes a model by its ID. This method must be implemented by subclasses.
29
+ *
30
+ * @param {string} _id - The ID of the model to retrieve.
31
+ * @throws {NotImplementedError} Throws if the method is not implemented.
32
+ * @returns {Promise<void>} - Returns a promise resolving when the model has been deleted.
33
+ * @abstract
34
+ */
35
+ static deleteById(_id) {
36
+ return Promise.reject(new NotImplementedError(`${this.name} must implement .deleteById()`));
37
+ }
38
+
27
39
  /**
28
40
  * Saves a model to the data store. This method must be implemented by subclasses.
29
41
  *
@@ -181,7 +193,10 @@ class Engine {
181
193
  await this.putModel(m);
182
194
 
183
195
  uploadedModels.push(m.id);
184
- indexUpdates[m.constructor.name] = (indexUpdates[m.constructor.name] ?? []).concat([m]);
196
+ indexUpdates[m.constructor.name] = {
197
+ ...indexUpdates[m.constructor.name] || {},
198
+ [m.id]: m,
199
+ };
185
200
 
186
201
  if (m.constructor.searchProperties().length > 0) {
187
202
  const rawSearchIndex = {
@@ -243,6 +258,127 @@ class Engine {
243
258
  }
244
259
  }
245
260
 
261
+ /**
262
+ * Deletes a model
263
+ *
264
+ * @param {Model} model
265
+ * @return {Promise<void>}
266
+ * @throws {NotFoundEngineError} Throws if the model is not found.
267
+ */
268
+ static async delete(model) {
269
+ this.checkConfiguration();
270
+
271
+ const modelUpdates = [];
272
+ const processedModels = [];
273
+ const indexUpdates = {};
274
+ const additionalDeletions = [];
275
+ const deletedModels = [];
276
+
277
+ /**
278
+ * Delete the given model, updating search indexes as required.
279
+ * @param {Model} m - The model to be deleted.
280
+ * @return {Promise<void>}
281
+ */
282
+ const deleteModel = async (m) => {
283
+ if (deletedModels.includes(m.id)) return;
284
+ if (m.constructor.searchProperties().length > 0) {
285
+ const rawSearchIndex = await this.getSearchIndexRaw(m.constructor);
286
+
287
+ delete rawSearchIndex[m.id];
288
+
289
+ await this.putSearchIndexRaw(m.constructor, rawSearchIndex);
290
+
291
+ const compiledIndex = lunr(function () {
292
+ this.ref('id');
293
+
294
+ for (const field of m.constructor.searchProperties()) {
295
+ this.field(field);
296
+ }
297
+
298
+ Object.values(rawSearchIndex).forEach(function (doc) {
299
+ this.add(doc);
300
+ }, this);
301
+ });
302
+
303
+ await this.putSearchIndexCompiled(m.constructor, compiledIndex);
304
+ }
305
+
306
+ if (m.constructor.indexedProperties().length > 0) {
307
+ indexUpdates[m.constructor.name] = {
308
+ ...indexUpdates[m.constructor.name] || {},
309
+ [m.id]: undefined,
310
+ };
311
+ }
312
+
313
+ await this.deleteById(m.id);
314
+ deletedModels.push(m.id);
315
+ };
316
+
317
+ /**
318
+ * Process updates to all sub-models of the given model.
319
+ * @param {Model} m - The model to process for updates.
320
+ * @return {Promise<void>}
321
+ */
322
+ const processModelUpdates = async (m) => {
323
+ if (!processedModels.includes(m.id)) {
324
+ processedModels.push(m.id);
325
+
326
+ for (const [key, property] of Object.entries(m)) {
327
+ if (Type.Model.isModel(property)) {
328
+ if (property.id === model.id) {
329
+ m[key] = undefined;
330
+ indexUpdates[m.constructor.name] = {
331
+ ...indexUpdates[m.constructor.name] || {},
332
+ [m.id]: m,
333
+ };
334
+ modelUpdates.push(m);
335
+ }
336
+
337
+ if (m.id !== model.id && (Type.Model.isModel(m.constructor[key]) ? m.constructor[key] : m.constructor[key]())._required) {
338
+ additionalDeletions.push(m);
339
+ }
340
+
341
+ await processModelUpdates(property);
342
+ }
343
+ if (Array.isArray(property) && Type.Model.isModel(property[0])) {
344
+ for (const [index, subModel] of property.entries()) {
345
+ if (subModel.id === model.id) {
346
+ m[key].splice(index, 1);
347
+ indexUpdates[m.constructor.name] = {
348
+ ...indexUpdates[m.constructor.name] || {},
349
+ [m.id]: m,
350
+ };
351
+ modelUpdates.push(m);
352
+ }
353
+ await processModelUpdates(subModel);
354
+ }
355
+ }
356
+ }
357
+ }
358
+ };
359
+
360
+ try {
361
+ const hydrated = await this.hydrate(model);
362
+ await processModelUpdates(hydrated);
363
+ await deleteModel(hydrated);
364
+
365
+ for (const updatedModel of modelUpdates) {
366
+ if (!additionalDeletions.map(m => m.id).includes(updatedModel.id)) {
367
+ await this.put(updatedModel);
368
+ }
369
+ }
370
+
371
+ for (const modelToBeDeleted of additionalDeletions) {
372
+ await deleteModel(modelToBeDeleted);
373
+ }
374
+
375
+ await this.putIndex(indexUpdates);
376
+ } catch (error) {
377
+ if (error.constructor === NotImplementedError) throw error;
378
+ throw new CannotDeleteEngineError(`${this.name}.delete(${model.id}) model cannot be deleted`, error);
379
+ }
380
+ }
381
+
246
382
  /**
247
383
  * Hydrates a model by populating its related properties (e.g., submodels) from stored data.
248
384
  *
@@ -258,8 +394,10 @@ class Engine {
258
394
 
259
395
  for (const [name, property] of Object.entries(modelToProcess)) {
260
396
  if (Type.Model.isDryModel(property)) {
397
+ // skipcq: JS-0129
261
398
  modelToProcess[name] = await hydrateSubModel(property, modelToProcess, name);
262
399
  } else if (Array.isArray(property) && Type.Model.isDryModel(property[0])) {
400
+ // skipcq: JS-0129
263
401
  modelToProcess[name] = await hydrateModelList(property, modelToProcess, name);
264
402
  }
265
403
  }
@@ -283,15 +421,15 @@ class Engine {
283
421
  const hydrateModelList = async (property, modelToProcess, name) => {
284
422
  const subModelClass = getSubModelClass(modelToProcess, name, true);
285
423
 
286
- const newModelList = await Promise.all(property.map(async subModel => {
424
+ const newModelList = await Promise.all(property.map(subModel => {
287
425
  if (hydratedModels[subModel.id]) {
288
426
  return hydratedModels[subModel.id];
289
427
  }
290
428
 
291
- return await this.get(subModelClass, subModel.id);
429
+ return this.get(subModelClass, subModel.id);
292
430
  }));
293
431
 
294
- return await Promise.all(newModelList.map(async subModel => {
432
+ return Promise.all(newModelList.map(async subModel => {
295
433
  if (hydratedModels[subModel.id]) {
296
434
  return hydratedModels[subModel.id];
297
435
  }
@@ -312,7 +450,7 @@ class Engine {
312
450
  return isArray ? constructorField._items : constructorField;
313
451
  }
314
452
 
315
- return await hydrateModel(await this.get(model.constructor, model.id));
453
+ return hydrateModel(await this.get(model.constructor, model.id));
316
454
  }
317
455
 
318
456
  /**
@@ -387,6 +525,19 @@ export class NotFoundEngineError extends EngineError {
387
525
  */
388
526
  }
389
527
 
528
+ /**
529
+ * Represents an error that occurs when a requested resource or item cannot be deleted by the engine.
530
+ * Extends the `EngineError` class.
531
+ */
532
+ export class CannotDeleteEngineError extends EngineError {
533
+ /**
534
+ * Creates an instance of `CannotDeleteEngineError`.
535
+ *
536
+ * @param {string} message - The error message.
537
+ * @param {Error} [error] - An optional underlying error that caused this error.
538
+ */
539
+ }
540
+
390
541
  /**
391
542
  * Represents an error indicating that a certain method or functionality is not implemented in the engine.
392
543
  * Extends the `EngineError` class.
@@ -64,6 +64,17 @@ class FileEngine extends Engine {
64
64
  .then(JSON.parse);
65
65
  }
66
66
 
67
+ /**
68
+ * Deletes a model by its ID from the file system.
69
+ *
70
+ * @param {string} id - The ID of the model to delete.
71
+ * @returns {Promise<void>} Resolves when the model has been deleted.
72
+ * @throws {Error} Throws if the file cannot be deleted.
73
+ */
74
+ static deleteById(id) {
75
+ return this.configuration.filesystem.rm(join(this.configuration.path, `${id}.json`));
76
+ }
77
+
67
78
  /**
68
79
  * Retrieves the index for a given model from the file system.
69
80
  *
@@ -98,19 +109,20 @@ class FileEngine extends Engine {
98
109
  /**
99
110
  * Saves the index for multiple models to the file system.
100
111
  *
101
- * @param {Object} index - An object where keys are locations and values are arrays of models.
112
+ * @param {Object} index - An object where keys are locations and values are key value pairs of models and their ids.
102
113
  * @throws {FailedWriteFileEngineError} Throws if the index cannot be written to the file system.
103
114
  */
104
115
  static async putIndex(index) {
105
116
  const processIndex = async (location, models) => {
106
- const modelIndex = Object.fromEntries(models.map((m) => [m.id, m.toIndexData()]));
107
117
  const filePath = join(this.configuration.path, location, '_index.json');
108
118
  const currentIndex = JSON.parse((await this.configuration.filesystem.readFile(filePath).catch(() => '{}')).toString());
109
119
 
110
120
  try {
111
121
  await this.configuration.filesystem.writeFile(filePath, JSON.stringify({
112
122
  ...currentIndex,
113
- ...modelIndex,
123
+ ...Object.fromEntries(
124
+ Object.entries(models).map(([k, v]) => [k, v?.toIndexData?.() || v]),
125
+ ),
114
126
  }));
115
127
  } catch (error) {
116
128
  throw new FailedWriteFileEngineError(`Failed to put file://${filePath}`, error);
@@ -121,7 +133,12 @@ class FileEngine extends Engine {
121
133
  await processIndex(location, models);
122
134
  }
123
135
 
124
- await processIndex('', Object.values(index).flat());
136
+ await processIndex('', Object.values(index).reduce((accumulator, currentValue) => {
137
+ Object.keys(currentValue).forEach(key => {
138
+ accumulator[key] = currentValue[key];
139
+ });
140
+ return accumulator;
141
+ }, {}));
125
142
  }
126
143
 
127
144
  /**
@@ -126,7 +126,7 @@ class HTTPEngine extends Engine {
126
126
  *
127
127
  * @throws {HTTPRequestFailedError} Thrown if the fetch request fails.
128
128
  */
129
- static async getById(id) {
129
+ static getById(id) {
130
130
  this.checkConfiguration();
131
131
 
132
132
  const url = new URL([
@@ -135,7 +135,26 @@ class HTTPEngine extends Engine {
135
135
  `${id}.json`,
136
136
  ].filter(e => Boolean(e)).join('/'));
137
137
 
138
- return await this._processFetch(url, this._getReadOptions());
138
+ return this._processFetch(url, this._getReadOptions());
139
+ }
140
+
141
+ /**
142
+ * Deletes a model by its ID from an HTTP server.
143
+ *
144
+ * @param {string} id - The ID of the model to delete.
145
+ * @returns {Promise<void>} Resolves when the model has been deleted.
146
+ * @throws {Error} Throws if the file cannot be deleted.
147
+ */
148
+ static deleteById(id) {
149
+ this.checkConfiguration();
150
+
151
+ const url = new URL([
152
+ this.configuration.host,
153
+ this.configuration.prefix,
154
+ `${id}.json`,
155
+ ].filter(e => Boolean(e)).join('/'));
156
+
157
+ return this._processFetch(url, {...this._getReadOptions(), method: 'DELETE'});
139
158
  }
140
159
 
141
160
  /**
@@ -162,14 +181,12 @@ class HTTPEngine extends Engine {
162
181
  /**
163
182
  * Uploads (puts) an index object to the server.
164
183
  *
165
- * @param {Object} index - The index data to upload, organized by location.
184
+ * @param {Object} index - An object where keys are locations and values are key value pairs of models and their ids.
166
185
  * @returns {Promise<void>}
167
- *
168
186
  * @throws {HTTPRequestFailedError} Thrown if the PUT request fails.
169
187
  */
170
188
  static async putIndex(index) {
171
189
  const processIndex = async (location, models) => {
172
- const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()]));
173
190
  const url = new URL([
174
191
  this.configuration.host,
175
192
  this.configuration.prefix,
@@ -177,11 +194,13 @@ class HTTPEngine extends Engine {
177
194
  '_index.json',
178
195
  ].filter(e => Boolean(e)).join('/'));
179
196
 
180
- return await this._processFetch(url, {
197
+ return this._processFetch(url, {
181
198
  ...this._getWriteOptions(),
182
199
  body: JSON.stringify({
183
200
  ...await this.getIndex(location),
184
- ...modelIndex,
201
+ ...Object.fromEntries(
202
+ Object.entries(models).map(([k, v]) => [k, v?.toIndexData?.() || v]),
203
+ ),
185
204
  }),
186
205
  });
187
206
  };
@@ -190,7 +209,12 @@ class HTTPEngine extends Engine {
190
209
  await processIndex(location, models);
191
210
  }
192
211
 
193
- await processIndex(null, Object.values(index).flat());
212
+ await processIndex(null, Object.values(index).reduce((accumulator, currentValue) => {
213
+ Object.keys(currentValue).forEach(key => {
214
+ accumulator[key] = currentValue[key];
215
+ });
216
+ return accumulator;
217
+ }, {}));
194
218
  }
195
219
 
196
220
  /**
@@ -199,7 +223,7 @@ class HTTPEngine extends Engine {
199
223
  * @param {Model.constructor?} model - The model in the host where the index is stored.
200
224
  * @returns {Promise<Object>} The index data in JSON format.
201
225
  */
202
- static async getIndex(model) {
226
+ static getIndex(model) {
203
227
  const url = new URL([
204
228
  this.configuration.host,
205
229
  this.configuration.prefix,
@@ -207,7 +231,7 @@ class HTTPEngine extends Engine {
207
231
  '_index.json',
208
232
  ].filter(e => Boolean(e)).join('/'));
209
233
 
210
- return await this._processFetch(url, this._getReadOptions(), {});
234
+ return this._processFetch(url, this._getReadOptions(), {});
211
235
  }
212
236
 
213
237
  /**
@@ -216,7 +240,7 @@ class HTTPEngine extends Engine {
216
240
  * @param {Model.constructor} model - The model whose compiled search index to retrieve.
217
241
  * @returns {Promise<Object>} The compiled search index in JSON format.
218
242
  */
219
- static async getSearchIndexCompiled(model) {
243
+ static getSearchIndexCompiled(model) {
220
244
  const url = new URL([
221
245
  this.configuration.host,
222
246
  this.configuration.prefix,
@@ -224,7 +248,7 @@ class HTTPEngine extends Engine {
224
248
  '_search_index.json',
225
249
  ].join('/'));
226
250
 
227
- return await this._processFetch(url, this._getReadOptions());
251
+ return this._processFetch(url, this._getReadOptions());
228
252
  }
229
253
 
230
254
  /**
@@ -233,7 +257,7 @@ class HTTPEngine extends Engine {
233
257
  * @param {Model.constructor} model - The model whose raw search index to retrieve.
234
258
  * @returns {Promise<Object>} The raw search index in JSON format, or an empty object if not found.
235
259
  */
236
- static async getSearchIndexRaw(model) {
260
+ static getSearchIndexRaw(model) {
237
261
  const url = new URL([
238
262
  this.configuration.host,
239
263
  this.configuration.prefix,
@@ -241,7 +265,7 @@ class HTTPEngine extends Engine {
241
265
  '_search_index_raw.json',
242
266
  ].join('/'));
243
267
 
244
- return await this._processFetch(url, this._getReadOptions()).catch(() => ({}));
268
+ return this._processFetch(url, this._getReadOptions()).catch(() => ({}));
245
269
  }
246
270
 
247
271
  /**
@@ -253,7 +277,7 @@ class HTTPEngine extends Engine {
253
277
  *
254
278
  * @throws {HTTPRequestFailedError} Thrown if the PUT request fails.
255
279
  */
256
- static async putSearchIndexCompiled(model, compiledIndex) {
280
+ static putSearchIndexCompiled(model, compiledIndex) {
257
281
  const url = new URL([
258
282
  this.configuration.host,
259
283
  this.configuration.prefix,
@@ -276,7 +300,7 @@ class HTTPEngine extends Engine {
276
300
  *
277
301
  * @throws {HTTPRequestFailedError} Thrown if the PUT request fails.
278
302
  */
279
- static async putSearchIndexRaw(model, rawIndex) {
303
+ static putSearchIndexRaw(model, rawIndex) {
280
304
  const url = new URL([
281
305
  this.configuration.host,
282
306
  this.configuration.prefix,
@@ -284,7 +308,7 @@ class HTTPEngine extends Engine {
284
308
  '_search_index_raw.json',
285
309
  ].filter(e => Boolean(e)).join('/'));
286
310
 
287
- return await this._processFetch(url, {
311
+ return this._processFetch(url, {
288
312
  ...this._getWriteOptions(),
289
313
  body: JSON.stringify(rawIndex),
290
314
  });
@@ -1,5 +1,5 @@
1
+ import {DeleteObjectCommand, GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3';
1
2
  import Engine, {EngineError, MissConfiguredError} from './Engine.js';
2
- import {GetObjectCommand, PutObjectCommand} from '@aws-sdk/client-s3';
3
3
 
4
4
  /**
5
5
  * Represents an error specific to the S3 engine operations.
@@ -68,6 +68,24 @@ class S3Engine extends Engine {
68
68
  return JSON.parse(await data.Body.transformToString());
69
69
  }
70
70
 
71
+ /**
72
+ * Deletes a model by its ID from theS3 bucket.
73
+ *
74
+ * @param {string} id - The ID of the model to delete.
75
+ * @returns {Promise<void>} Resolves when the model has been deleted.
76
+ * @throws {Error} Throws if the model cannot be deleted.
77
+ */
78
+ static async deleteById(id) {
79
+ const objectPath = [this.configuration.prefix, `${id}.json`].join('/');
80
+
81
+ await this.configuration.client.send(new DeleteObjectCommand({
82
+ Bucket: this.configuration.bucket,
83
+ Key: objectPath,
84
+ }));
85
+
86
+ return undefined;
87
+ }
88
+
71
89
  /**
72
90
  * Puts (uploads) a model object to S3.
73
91
  *
@@ -113,16 +131,13 @@ class S3Engine extends Engine {
113
131
  /**
114
132
  * Puts (uploads) an index object to S3.
115
133
  *
116
- * @param {Object} index - The index data to upload, organized by location.
134
+ * @param {Object} index - An object where keys are locations and values are key value pairs of models and their ids.
117
135
  * @returns {Promise<void>}
118
- *
119
136
  * @throws {FailedPutS3EngineError} Thrown if there is an error during the S3 PutObject operation.
120
137
  */
121
138
  static async putIndex(index) {
122
139
  const processIndex = async (location, models) => {
123
- const modelIndex = Object.fromEntries(models.map(m => [m.id, m.toIndexData()]));
124
140
  const Key = [this.configuration.prefix, location, '_index.json'].filter(e => Boolean(e)).join('/');
125
-
126
141
  const currentIndex = await this.getIndex(location);
127
142
 
128
143
  try {
@@ -132,7 +147,9 @@ class S3Engine extends Engine {
132
147
  ContentType: 'application/json',
133
148
  Body: JSON.stringify({
134
149
  ...currentIndex,
135
- ...modelIndex,
150
+ ...Object.fromEntries(
151
+ Object.entries(models).map(([k, v]) => [k, v?.toIndexData?.() || v]),
152
+ ),
136
153
  }),
137
154
  }));
138
155
  } catch (error) {
@@ -144,7 +161,12 @@ class S3Engine extends Engine {
144
161
  await processIndex(location, models);
145
162
  }
146
163
 
147
- await processIndex(null, Object.values(index).flat());
164
+ await processIndex(null, Object.values(index).reduce((accumulator, currentValue) => {
165
+ Object.keys(currentValue).forEach(key => {
166
+ accumulator[key] = currentValue[key];
167
+ });
168
+ return accumulator;
169
+ }, {}));
148
170
  }
149
171
 
150
172
  /**
@@ -153,9 +175,9 @@ class S3Engine extends Engine {
153
175
  * @param {Model.constructor} model - The model whose search index to retrieve.
154
176
  * @returns {Promise<Object>} The compiled search index.
155
177
  */
156
- static async getSearchIndexCompiled(model) {
157
- return await this.configuration.client.send(new GetObjectCommand({
158
- Key: [this.configuration.prefix, model.name, '_search_index.json'].join('/'),
178
+ static getSearchIndexCompiled(model) {
179
+ return this.configuration.client.send(new GetObjectCommand({
180
+ Key: [this.configuration.prefix, model.toString(), '_search_index.json'].join('/'),
159
181
  Bucket: this.configuration.bucket,
160
182
  })).then(data => data.Body.transformToString())
161
183
  .then(JSON.parse);
@@ -167,8 +189,8 @@ class S3Engine extends Engine {
167
189
  * @param {Model.constructor} model - The model whose raw search index to retrieve.
168
190
  * @returns {Promise<Object>} The raw search index, or an empty object if not found.
169
191
  */
170
- static async getSearchIndexRaw(model) {
171
- return await this.configuration.client.send(new GetObjectCommand({
192
+ static getSearchIndexRaw(model) {
193
+ return this.configuration.client.send(new GetObjectCommand({
172
194
  Key: [this.configuration.prefix, model.toString(), '_search_index_raw.json'].join('/'),
173
195
  Bucket: this.configuration.bucket,
174
196
  })).then(data => data.Body.transformToString())
package/src/type/Model.js CHANGED
@@ -40,7 +40,7 @@ class Model {
40
40
  }
41
41
  if (value?._resolved) {
42
42
  Object.defineProperty(this, key, {
43
- get: function () {
43
+ get() {
44
44
  return value.resolve(this);
45
45
  },
46
46
  });
@@ -53,6 +53,8 @@ class SlugType extends ResolvedType {
53
53
 
54
54
  return slugify(model?.[property], {
55
55
  lower: true,
56
+ strict: true,
57
+ trim: true,
56
58
  });
57
59
  }
58
60
  }