@vida-global/core 2.1.1 → 2.2.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.
@@ -98,7 +98,7 @@ module.exports = {
98
98
  Available migration helpers:
99
99
 
100
100
  ```js
101
- createTable(tableName, details, { timestamps })
101
+ createTable(tableName, details, { timestamps, paranoid })
102
102
  dropTable(tableName)
103
103
  addColumn(tableName, columnName, columnDetails)
104
104
  removeColumn(tableName, columnName)
@@ -111,6 +111,7 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
111
111
  **Conventions:**
112
112
 
113
113
  - `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
114
+ - Pass `{ paranoid: true }` to add a nullable `deleted_at` column. Models over that table soft delete: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at`, so `destroy()` sets the column and ordinary queries skip the row. Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a deleted row will still block re-creating the same one.
114
115
  - Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
115
116
  - Each migration runs inside a transaction.
116
117
 
@@ -307,8 +308,3 @@ class User extends ActiveRecord.BaseRecord {
307
308
  ```
308
309
 
309
310
  See [Sequelize validators](https://sequelize.org/docs/v6/core-concepts/validations-and-constraints/).
310
-
311
-
312
- ## TODO
313
-
314
- - **Paranoid (soft delete).** The infrastructure for a `deleted_at` column is wired in `initializationOptions`, but `paranoid: true` is not set, so soft deletes are not yet usable out of the box.
@@ -33,21 +33,74 @@ class BaseRecord extends Model {
33
33
  /***********************************************************************************************
34
34
  * QUERIES
35
35
  ***********************************************************************************************/
36
- static async find(ids) {
36
+ static async find(ids, { useWriteDB=false, ...options }={}) {
37
37
  if (this.isCacheable) {
38
- return await this._cachedFind(...arguments);
38
+ return await this._cachedFind(ids, options);
39
39
  } else if (Array.isArray(ids)) {
40
40
  const pk = this.primaryKeyAttribute
41
- return await this.where({[pk]: ids});
41
+ return await this.where({[pk]: ids}, { useMaster: useWriteDB });
42
42
  } else {
43
- return await this.findByPk(ids);
43
+ return await this.findByPk(ids, { useMaster: useWriteDB });
44
44
  }
45
45
  }
46
46
 
47
47
 
48
48
  static async where(where, options={}) {
49
- const parameters = {...options, where };
50
- return await this.findAll(parameters);
49
+ const { page, pageSize, ...queryOptions } = options;
50
+
51
+ if (!page && !pageSize) {
52
+ const parameters = {...queryOptions, where };
53
+ return await this.findAll(parameters);
54
+ }
55
+
56
+ const offset = pageSize * (page - 1);
57
+ const rows = await this.findAll({
58
+ ...queryOptions,
59
+ where,
60
+ order: queryOptions.order || [this.defaultSort()],
61
+ limit: pageSize,
62
+ offset,
63
+ });
64
+
65
+ const totalCount = await this.paginatedTotalCount(rows, where,
66
+ {
67
+ offset,
68
+ pageSize,
69
+ queryOptions,
70
+ })
71
+
72
+ return { rows, totalCount }
73
+ }
74
+
75
+
76
+ static async transaction(callback) {
77
+ return await this.connection.queryInterface.transaction(callback);
78
+ }
79
+
80
+
81
+ static defaultSort() {
82
+ return ['created_at', 'DESC'];
83
+ }
84
+
85
+
86
+ static async paginatedTotalCount(rows, where, { offset, pageSize, queryOptions }) {
87
+ const isFullPage = rows.length === pageSize
88
+ const isEmptyPageAfterFirst = rows.length === 0 && offset > 0
89
+
90
+ if (isFullPage || isEmptyPageAfterFirst) {
91
+ return await this.count({...queryOptions, where })
92
+ }
93
+
94
+ return offset + rows.length
95
+ }
96
+
97
+
98
+ static async execute(query, { raw=false, ...options }={}) {
99
+ return await this.sequelize.query(query, {
100
+ type: Sequelize.QueryTypes.SELECT,
101
+ ...(raw ? {} : { model: this, mapToModel: true }),
102
+ ...options
103
+ });
51
104
  }
52
105
 
53
106
 
@@ -78,10 +131,16 @@ class BaseRecord extends Model {
78
131
  updatedAt: 'updated_at',
79
132
  }
80
133
 
81
- if (!schema.created_at || !schema.updated_at) {
134
+ const hasTimestamps = schema.created_at && schema.updated_at;
135
+
136
+ if (!hasTimestamps) {
82
137
  options.timestamps = false;
83
138
  }
84
139
 
140
+ if (hasTimestamps && schema.deleted_at) {
141
+ options.paranoid = true;
142
+ }
143
+
85
144
  this.configureOverridenAccessors(schema);
86
145
  this.configureVirtualAccessors(schema);
87
146
 
@@ -278,30 +337,39 @@ class BaseRecord extends Model {
278
337
  }
279
338
 
280
339
 
281
- static _marshallDataForCaching(value) {
282
- let marshalledValue;
283
- if (value instanceof this) {
284
- marshalledValue = JSON.stringify(value.toJSON());
285
- marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
286
- } else {
287
- marshalledValue = JSON.stringify(value);
288
- }
289
- return marshalledValue;
340
+ static async clearCacheKey(key) {
341
+ const client = await this._getRedisClient();
342
+ await client.del(key);
290
343
  }
291
344
 
292
345
 
293
- static _unmarshallCachedData(value) {
294
- if (value == null) return null;
295
- if (typeof value != 'string') return value;
346
+ static async forEachCacheKeyByPattern(pattern, callback, count=100) {
347
+ const client = await this._getRedisClient();
348
+ let batch = [];
296
349
 
297
- if (value.startsWith(this.recordCachingPrefix)) {
298
- const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
299
- value = value.replace(regExp, '');
300
- const data = JSON.parse(value);
301
- return new this(data, {isNewRecord: false});
302
- } else {
303
- return JSON.parse(value);
350
+ for await (const scanned of client.scanIterator({ MATCH: pattern, COUNT: count })) {
351
+ batch.push(...this._scannedKeysAsArray(scanned));
352
+ if (batch.length < count) continue;
353
+
354
+ await callback(batch, client);
355
+ batch = [];
304
356
  }
357
+
358
+ if (batch.length) await callback(batch, client);
359
+ }
360
+
361
+
362
+ // redis 4 yields one key at a time; redis 5 yields an array of keys per scan.
363
+ static _scannedKeysAsArray(scanned) {
364
+ if (Array.isArray(scanned)) return scanned;
365
+ return [scanned];
366
+ }
367
+
368
+
369
+ static async clearCacheKeysByPattern(pattern) {
370
+ await this.forEachCacheKeyByPattern(pattern, async (keys, client) => {
371
+ await client.del(keys);
372
+ });
305
373
  }
306
374
 
307
375
 
@@ -310,9 +378,8 @@ class BaseRecord extends Model {
310
378
 
311
379
  const pk = this.constructor.primaryKeyAttribute;
312
380
  const key = this.constructor._recordCacheKey(this[pk]);
313
- const client = await this.constructor._getRedisClient();
381
+ await this.constructor.clearCacheKey(key);
314
382
 
315
- await client.del(key);
316
383
  this.constructor.debugLog('Cache Del', key);
317
384
  }
318
385
 
@@ -330,18 +397,6 @@ class BaseRecord extends Model {
330
397
  }
331
398
 
332
399
 
333
- static get redisClientFactory() {
334
- return redisClientFactory;
335
- }
336
-
337
-
338
- static async _getRedisClient() {
339
- const client = this.redisClientFactory();
340
- await client.connect();
341
- return client;
342
- }
343
-
344
-
345
400
  static get isCacheable() { return false; }
346
401
 
347
402
 
@@ -357,6 +412,45 @@ class BaseRecord extends Model {
357
412
  }
358
413
 
359
414
 
415
+ static _marshallDataForCaching(value) {
416
+ let marshalledValue;
417
+ if (value instanceof this) {
418
+ marshalledValue = JSON.stringify(value.toJSON());
419
+ marshalledValue = `${this.recordCachingPrefix}:${marshalledValue}`;
420
+ } else {
421
+ marshalledValue = JSON.stringify(value);
422
+ }
423
+ return marshalledValue;
424
+ }
425
+
426
+
427
+ static _unmarshallCachedData(value) {
428
+ if (value == null) return null;
429
+ if (typeof value != 'string') return value;
430
+
431
+ if (value.startsWith(this.recordCachingPrefix)) {
432
+ const regExp = new RegExp(`^${this.recordCachingPrefix}:`);
433
+ value = value.replace(regExp, '');
434
+ const data = JSON.parse(value);
435
+ return new this(data, {isNewRecord: false});
436
+ } else {
437
+ return JSON.parse(value);
438
+ }
439
+ }
440
+
441
+
442
+ static get _redisClientFactory() {
443
+ return redisClientFactory;
444
+ }
445
+
446
+
447
+ static async _getRedisClient() {
448
+ const client = this._redisClientFactory();
449
+ await client.connect();
450
+ return client;
451
+ }
452
+
453
+
360
454
  /***********************************************************************************************
361
455
  * MISC
362
456
  ***********************************************************************************************/
@@ -376,17 +470,33 @@ class BaseRecord extends Model {
376
470
  toApiResponse() {
377
471
  const response = {};
378
472
  for (const column of this.apiResponseColumns) {
379
- response[column] = this[column]; // Use overridden getters when available
473
+ response[this.apiResponseKey(column)] = this[column]; // Use overridden getters when available
380
474
  }
381
475
  return response;
382
476
  }
383
477
 
384
478
 
479
+ toObject() {
480
+ return this.toApiResponse();
481
+ }
482
+
483
+
385
484
  get apiResponseColumns() {
386
485
  return Object.keys(this.constructor.documentationSchema());
387
486
  }
388
487
 
389
488
 
489
+ apiResponseKey(column) {
490
+ if (!this.camelizeFieldsForApiResponse) return column;
491
+ return camelize(column, true);
492
+ }
493
+
494
+
495
+ get camelizeFieldsForApiResponse() {
496
+ return false;
497
+ }
498
+
499
+
390
500
  /***********************************************************************************************
391
501
  * DOCUMENTATION
392
502
  ***********************************************************************************************/
@@ -72,6 +72,9 @@ class Migrator {
72
72
  if (options.timestamps !== false) {
73
73
  details = {...details, ...this.defaultTimestampColumns};
74
74
  }
75
+ if (options.paranoid) {
76
+ details = {...details, ...this.deletedAtColumn};
77
+ }
75
78
 
76
79
  details = this.normalizeTableDetails(details);
77
80
  await this.#sequelizeQueryInterface.createTable(tableName, details);
@@ -113,6 +116,17 @@ class Migrator {
113
116
  }
114
117
 
115
118
 
119
+ // Records use soft deletes when the table has this column: initializationOptions turns paranoid
120
+ // on for any model whose schema includes it.
121
+ get deletedAtColumn() {
122
+ return {
123
+ deleted_at: {
124
+ allowNull: true,
125
+ type: this.DataTypes.DATE }
126
+ };
127
+ }
128
+
129
+
116
130
  async dropTable(tableName) {
117
131
  await this.#sequelizeQueryInterface.dropTable(tableName);
118
132
  }
@@ -25,7 +25,7 @@ class QueryInterface {
25
25
 
26
26
 
27
27
  async transaction(callback) {
28
- await this.#sequelize.transaction(callback);
28
+ return await this.#sequelize.transaction(callback);
29
29
  }
30
30
 
31
31
 
@@ -14,6 +14,16 @@ class MemoryCache {
14
14
  }
15
15
 
16
16
 
17
+ async delete({ namespace, key } = {}) {
18
+ this.#validateNamespace(namespace);
19
+
20
+ const cacheKey = this.#createCacheKey({ namespace, key });
21
+ const entryDeleted = this.#entries.delete(cacheKey);
22
+ const fetchInvalidated = this.#removePendingFetch(cacheKey);
23
+ return entryDeleted || fetchInvalidated;
24
+ }
25
+
26
+
17
27
  async fetch(options = {}, fetcher) {
18
28
  this.#validateNamespace(options.namespace);
19
29
  this.#validateTtl(options.ttlSeconds);
@@ -26,13 +36,15 @@ class MemoryCache {
26
36
  const pending = this.#pendingFetches.get(cacheKey);
27
37
  if (pending) return this.#buildFetchResult(await pending, false);
28
38
 
29
- const fetch = this.#fetch(options, fetcher, cacheKey);
39
+ const fetch = this.#runFetcher(fetcher);
30
40
  this.#pendingFetches.set(cacheKey, fetch);
31
41
 
32
42
  try {
33
- return this.#buildFetchResult(await fetch, false);
43
+ const value = await fetch;
44
+ this.#cacheFetchedValue(cacheKey, options.ttlSeconds, fetch, value);
45
+ return this.#buildFetchResult(value, false);
34
46
  } finally {
35
- this.#pendingFetches.delete(cacheKey);
47
+ this.#removePendingFetch(cacheKey, fetch);
36
48
  }
37
49
  }
38
50
 
@@ -46,10 +58,22 @@ class MemoryCache {
46
58
  }
47
59
 
48
60
 
49
- async #fetch(options, fetcher, cacheKey) {
50
- const value = await fetcher();
51
- this.#set(cacheKey, options.ttlSeconds, value);
52
- return value;
61
+ async #runFetcher(fetcher) {
62
+ return fetcher();
63
+ }
64
+
65
+
66
+ #cacheFetchedValue(cacheKey, ttlSeconds, fetch, value) {
67
+ if (this.#pendingFetches.get(cacheKey) !== fetch) return;
68
+ this.#set(cacheKey, ttlSeconds, value);
69
+ }
70
+
71
+
72
+ #removePendingFetch(cacheKey, expectedFetch) {
73
+ const currentFetch = this.#pendingFetches.get(cacheKey);
74
+ if (expectedFetch !== undefined && currentFetch !== expectedFetch) return false;
75
+
76
+ return this.#pendingFetches.delete(cacheKey);
53
77
  }
54
78
 
55
79
 
@@ -51,6 +51,9 @@ function setSseHeaders(controller) {
51
51
 
52
52
 
53
53
  const InstanceMethods = {
54
+ MIME_TYPES: CONTENT_TYPE_BY_EXTENSION,
55
+
56
+
54
57
  async render(body, options={}) {
55
58
  if (this.rendered) return;
56
59
  if (this.isStreaming) return;
@@ -76,21 +79,21 @@ const InstanceMethods = {
76
79
 
77
80
 
78
81
  renderJSONResponse(body) {
79
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.json);
82
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.json;
80
83
  this._response.json(body);
81
84
  },
82
85
 
83
86
 
84
87
  renderXMLResponse(body) {
85
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.xml);
86
- const doc = create({ response: body });
88
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.xml;
89
+ const doc = create({ [this.xmlRootElement]: body });
87
90
  const xml = doc.end();
88
91
  this._response.send(xml);
89
92
  },
90
93
 
91
94
 
92
95
  renderTextResponse(body) {
93
- this.setHeader('content-type', CONTENT_TYPE_BY_EXTENSION.txt);
96
+ if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.txt;
94
97
  this._response.send(body);
95
98
  },
96
99
 
@@ -163,7 +166,7 @@ const InstanceMethods = {
163
166
 
164
167
 
165
168
  _serializeDateForResponseBody(date, options) {
166
- return date.getTime();
169
+ return Math.floor(date.getTime() / 1000);
167
170
  },
168
171
 
169
172
 
@@ -186,33 +189,6 @@ const InstanceMethods = {
186
189
  },
187
190
 
188
191
 
189
- _setupStatusRenderers() {
190
- const successStatuses = {
191
- CreationSuccessful: 201,
192
- Accepted: 202,
193
- NoConent: 204,
194
- MovedPermanently: 301,
195
- Found: 302,
196
- NotModified: 304,
197
- TemporaryRedirect: 307,
198
- PermanentRedirect: 308,
199
- }
200
-
201
- for (let [type, statusCode] of Object.entries(Errors.Statuses)) {
202
- if (type == 'Server') type = 'ServerError';
203
- this[`render${type}`] = async function(message=null, body=null) {
204
- return await this._renderError(statusCode, message, body);
205
- }
206
- }
207
-
208
- for (let [type, statusCode] of Object.entries(successStatuses)) {
209
- this[`render${type}`] = async function(body=null) {
210
- return await this._renderSuccess(statusCode, body);
211
- }
212
- }
213
- },
214
-
215
-
216
192
  async _renderError(statusCode, message, body) {
217
193
  this.statusCode = statusCode;
218
194
  body = structuredClone(body || {});
@@ -305,7 +281,37 @@ const InstanceMethods = {
305
281
  }
306
282
 
307
283
 
284
+ const StatusRenderSuccessStatuses = {
285
+ CreationSuccessful: 201,
286
+ Accepted: 202,
287
+ NoContent: 204,
288
+ MovedPermanently: 301,
289
+ Found: 302,
290
+ NotModified: 304,
291
+ TemporaryRedirect: 307,
292
+ PermanentRedirect: 308,
293
+ }
294
+
295
+
308
296
  const StaticMethods = {
297
+ // Define render<Status> helpers on the prototype (not per instance) so subclasses and mixins
298
+ // can override them with a normal method of the same name.
299
+ setupStatusRenderers() {
300
+ for (let [type, statusCode] of Object.entries(Errors.Statuses)) {
301
+ if (type == 'Server') type = 'ServerError';
302
+ this.prototype[`render${type}`] = async function(message=null, body=null) {
303
+ return await this._renderError(statusCode, message, body);
304
+ }
305
+ }
306
+
307
+ for (const [type, statusCode] of Object.entries(StatusRenderSuccessStatuses)) {
308
+ this.prototype[`render${type}`] = async function(body=null) {
309
+ return await this._renderSuccess(statusCode, body);
310
+ }
311
+ }
312
+ },
313
+
314
+
309
315
  formatResponseBody(body, errors, { statusCode, statusText }) {
310
316
  const response = {data: body, status: statusText};
311
317
  if (errors) response.errors = errors;
@@ -325,7 +331,6 @@ const Accessors = {
325
331
  get() {
326
332
  const override = `${this.params._format || ''}`.toLowerCase();
327
333
  if (override === 'xml' || override === 'json') return override;
328
- if (/\bxml\b/i.test(this.requestHeaders.accept || '')) return 'xml';
329
334
  return 'json';
330
335
  }
331
336
  },
@@ -341,6 +346,19 @@ const Accessors = {
341
346
  },
342
347
 
343
348
 
349
+ responseContentType: {
350
+ get() { return this._response.getHeader('content-type') },
351
+ set(type) { return this._response.setHeader('content-type', type) }
352
+ },
353
+
354
+
355
+ // Root element wrapped around XML response bodies. Override in a subclass to emit a custom
356
+ // root (e.g. a TwiML `<Response>` for a telecom webhook).
357
+ xmlRootElement: {
358
+ get() { return 'response' }
359
+ },
360
+
361
+
344
362
  statusText: {
345
363
  get() {
346
364
  return STATUS_TEXTS[this.statusCode];
@@ -47,7 +47,7 @@ Don't set `this.statusCode` directly — use the helper that pairs the status wi
47
47
  |---|---|
48
48
  | 201 Created | `this.renderCreationSuccessful(body)` |
49
49
  | 202 Accepted | `this.renderAccepted(body)` |
50
- | 204 No Content | `this.renderNoConent(body)` |
50
+ | 204 No Content | `this.renderNoContent(body)` |
51
51
  | 301 Moved Permanently | `this.renderMovedPermanently(body)` |
52
52
  | 302 Found | `this.renderFound(body)` |
53
53
  | 304 Not Modified | `this.renderNotModified(body)` |
@@ -23,7 +23,6 @@ class VidaServerController {
23
23
 
24
24
  this._applyCallbacks();
25
25
  this._applyDocumentation();
26
- this._setupStatusRenderers();
27
26
  }
28
27
 
29
28
 
@@ -260,6 +259,8 @@ VidaServerController.loadMixin(RendererMixin);
260
259
  VidaServerController.loadMixin(RoutingMixin);
261
260
  VidaServerController.loadMixin(ValidationMixin);
262
261
 
262
+ VidaServerController.setupStatusRenderers();
263
+
263
264
 
264
265
  /***************************************************************************************************
265
266
  * ERRORS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",