@acodeninja/persist 2.3.2 → 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.2",
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": {
@@ -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
  *
@@ -389,6 +525,19 @@ export class NotFoundEngineError extends EngineError {
389
525
  */
390
526
  }
391
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
+
392
541
  /**
393
542
  * Represents an error indicating that a certain method or functionality is not implemented in the engine.
394
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
  /**
@@ -138,6 +138,25 @@ class HTTPEngine extends Engine {
138
138
  return this._processFetch(url, this._getReadOptions());
139
139
  }
140
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'});
158
+ }
159
+
141
160
  /**
142
161
  * Uploads (puts) a model object to the server.
143
162
  *
@@ -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,
@@ -181,7 +198,9 @@ class HTTPEngine extends Engine {
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
  /**
@@ -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
  /**