velocious 1.0.484 → 1.0.486

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.
@@ -0,0 +1,407 @@
1
+ // @ts-check
2
+
3
+ import UUID from "pure-uuid"
4
+
5
+ /**
6
+ * AuditChanges type.
7
+ * @typedef {Record<string, ?>} AuditChanges
8
+ */
9
+
10
+ /**
11
+ * AuditEventPayload type.
12
+ * @typedef {object} AuditEventPayload
13
+ * @property {string} action - Audit action name.
14
+ * @property {number | string} auditId - Created audit row id.
15
+ * @property {AuditChanges | null} auditedChanges - Changes captured for the audit.
16
+ * @property {Record<string, ?> | null} params - Optional caller-supplied audit params.
17
+ * @property {import("./index.js").default} record - Audited record.
18
+ */
19
+
20
+ /**
21
+ * AuditCallback type.
22
+ * @typedef {(payload: AuditEventPayload) => void | Promise<void>} AuditCallback
23
+ */
24
+
25
+ /**
26
+ * CreateAuditArgs type.
27
+ * @typedef {object} CreateAuditArgs
28
+ * @property {string} action - Audit action name.
29
+ * @property {AuditChanges | null} [auditedChanges] - Explicit changes to persist.
30
+ * @property {Record<string, ?> | null} [params] - Optional metadata to store with the audit.
31
+ */
32
+
33
+ /**
34
+ * AuditedModelClass type.
35
+ * @typedef {typeof import("./index.js").default & {
36
+ * _auditCallbacks?: Record<string, AuditCallback[]>,
37
+ * _auditLifecycleCallbacksRegistered?: boolean
38
+ * }} AuditedModelClass
39
+ */
40
+
41
+ /**
42
+ * Adds automatic create/update/destroy auditing callbacks to a model class.
43
+ * @param {AuditedModelClass} modelClass - Model class to audit.
44
+ * @returns {void}
45
+ */
46
+ function registerAuditing(modelClass) {
47
+ if (Object.prototype.hasOwnProperty.call(modelClass, "_auditLifecycleCallbacksRegistered") && modelClass._auditLifecycleCallbacksRegistered) {
48
+ return
49
+ }
50
+
51
+ modelClass._auditLifecycleCallbacksRegistered = true
52
+ modelClass.beforeCreate("captureCreateAuditChanges")
53
+ modelClass.afterCreate("createCreateAudit")
54
+ modelClass.beforeUpdate("captureUpdateAuditChanges")
55
+ modelClass.afterUpdate("createUpdateAudit")
56
+ modelClass.afterDestroy("createDestroyAudit")
57
+ }
58
+
59
+ /**
60
+ * Registers an audit event callback for a model/action pair.
61
+ * @param {AuditedModelClass} modelClass - Model class to observe.
62
+ * @param {string} action - Audit action name.
63
+ * @param {AuditCallback} callback - Callback invoked after matching audit rows are created.
64
+ * @returns {() => void} Unsubscribe function.
65
+ */
66
+ function registerAuditCallback(modelClass, action, callback) {
67
+ const normalizedAction = normalizeAction(action)
68
+ const callbacks = auditCallbacksForModelClass(modelClass)
69
+
70
+ if (!callbacks[normalizedAction]) {
71
+ callbacks[normalizedAction] = []
72
+ }
73
+
74
+ callbacks[normalizedAction].push(callback)
75
+
76
+ return () => {
77
+ const actionCallbacks = callbacks[normalizedAction]
78
+ const index = actionCallbacks.indexOf(callback)
79
+
80
+ if (index >= 0) {
81
+ actionCallbacks.splice(index, 1)
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Captures the new values for fields changed on a record.
88
+ * @param {import("./index.js").default} record - Record whose pending changes should be captured.
89
+ * @returns {AuditChanges} New values keyed by attribute name.
90
+ */
91
+ function auditChangesForCurrentChanges(record) {
92
+ const changes = record.changes()
93
+ /** @type {AuditChanges} */
94
+ const auditedChanges = {}
95
+ const columnNameToAttributeName = record.getModelClass().getColumnNameToAttributeNameMap()
96
+
97
+ for (const [attributeName, change] of Object.entries(changes)) {
98
+ auditedChanges[columnNameToAttributeName[attributeName] || attributeName] = change[1]
99
+ }
100
+
101
+ return auditedChanges
102
+ }
103
+
104
+ /**
105
+ * Captures the current attributes for a destroy audit.
106
+ * @param {import("./index.js").default} record - Record being destroyed.
107
+ * @returns {AuditChanges} Current attributes keyed by attribute name.
108
+ */
109
+ function auditChangesForDestroy(record) {
110
+ return {...record.attributes()}
111
+ }
112
+
113
+ /**
114
+ * Creates an audit row for a record.
115
+ * @param {import("./index.js").default} record - Record to audit.
116
+ * @param {CreateAuditArgs} args - Audit row options.
117
+ * @returns {Promise<number | string>} Created audit row id.
118
+ */
119
+ async function createAudit(record, args) {
120
+ const modelClass = /** @type {AuditedModelClass} */ (record.getModelClass())
121
+
122
+ return await modelClass._getConfiguration().ensureConnections({name: `${modelClass.getModelName()} audit`}, async () => {
123
+ return await createAuditWithCurrentConnection(record, args, modelClass)
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Creates an audit row using the current model database connection.
129
+ * @param {import("./index.js").default} record - Record to audit.
130
+ * @param {CreateAuditArgs} args - Audit row options.
131
+ * @param {AuditedModelClass} modelClass - Audited model class.
132
+ * @returns {Promise<number | string>} Created audit row id.
133
+ */
134
+ async function createAuditWithCurrentConnection(record, args, modelClass) {
135
+ if (!record.isPersisted()) throw new Error(`Cannot audit unpersisted ${modelClass.getModelName()} record`)
136
+
137
+ const action = normalizeAction(args.action)
138
+ const auditedChanges = args.auditedChanges === undefined ? null : args.auditedChanges
139
+ const params = args.params === undefined ? null : args.params
140
+ const db = modelClass.connection()
141
+ const currentDate = new Date()
142
+ const auditActionId = await findOrCreateLookupId({
143
+ columnName: "action",
144
+ db,
145
+ tableName: "audit_actions",
146
+ value: action,
147
+ currentDate
148
+ })
149
+ const auditAuditableTypeId = await findOrCreateLookupId({
150
+ columnName: "name",
151
+ db,
152
+ tableName: "audit_auditable_types",
153
+ value: modelClass.getModelName(),
154
+ currentDate
155
+ })
156
+ const auditId = new UUID(4).format()
157
+
158
+ const insertResult = await db.query(db.insertSql({
159
+ returnLastInsertedColumnNames: ["id"],
160
+ tableName: "audits",
161
+ data: {
162
+ id: auditId,
163
+ audit_action_id: auditActionId,
164
+ audit_auditable_type_id: auditAuditableTypeId,
165
+ auditable_id: record.id(),
166
+ auditable_type: modelClass.getModelName(),
167
+ audited_changes: auditedChanges,
168
+ params,
169
+ created_at: currentDate,
170
+ updated_at: currentDate
171
+ }
172
+ }))
173
+ const insertedAuditId = auditIdFromInsertResult(insertResult) ?? auditId
174
+
175
+ await emitAuditEvent(modelClass, action, {
176
+ action,
177
+ auditId: insertedAuditId,
178
+ auditedChanges,
179
+ params,
180
+ record
181
+ })
182
+
183
+ return insertedAuditId
184
+ }
185
+
186
+ /**
187
+ * Reads an audit id returned by INSERT ... RETURNING / OUTPUT.
188
+ * @param {Array<Record<string, ?>> | null | undefined} insertResult - Insert query result.
189
+ * @returns {number | string | null} Created audit id when the driver returned it.
190
+ */
191
+ function auditIdFromInsertResult(insertResult) {
192
+ if (!Array.isArray(insertResult)) return null
193
+
194
+ const row = insertResult[0]
195
+
196
+ if (!row) return null
197
+
198
+ const id = row.id
199
+
200
+ if (typeof id == "number" || typeof id == "string") return id
201
+
202
+ return null
203
+ }
204
+
205
+ /**
206
+ * Captures create changes on a model instance.
207
+ * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to capture.
208
+ * @returns {void}
209
+ */
210
+ function captureCreateAuditChanges(record) {
211
+ record._pendingCreateAuditChanges = auditChangesForCurrentChanges(record)
212
+ }
213
+
214
+ /**
215
+ * Creates the create audit row for a model instance.
216
+ * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to audit.
217
+ * @returns {Promise<void>}
218
+ */
219
+ async function createCreateAudit(record) {
220
+ await createAudit(record, {
221
+ action: "create",
222
+ auditedChanges: record._pendingCreateAuditChanges || null
223
+ })
224
+
225
+ record._pendingCreateAuditChanges = undefined
226
+ }
227
+
228
+ /**
229
+ * Captures update changes on a model instance.
230
+ * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to capture.
231
+ * @returns {void}
232
+ */
233
+ function captureUpdateAuditChanges(record) {
234
+ record._pendingUpdateAuditChanges = auditChangesForCurrentChanges(record)
235
+ }
236
+
237
+ /**
238
+ * Creates the update audit row for a model instance.
239
+ * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to audit.
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function createUpdateAudit(record) {
243
+ const auditedChanges = record._pendingUpdateAuditChanges || null
244
+
245
+ record._pendingUpdateAuditChanges = undefined
246
+
247
+ if (!auditedChanges || Object.keys(auditedChanges).length <= 0) return
248
+
249
+ await createAudit(record, {
250
+ action: "update",
251
+ auditedChanges
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Creates the destroy audit row for a model instance.
257
+ * @param {import("./index.js").default} record - Record to audit.
258
+ * @returns {Promise<void>}
259
+ */
260
+ async function createDestroyAudit(record) {
261
+ await createAudit(record, {
262
+ action: "destroy",
263
+ auditedChanges: auditChangesForDestroy(record)
264
+ })
265
+ }
266
+
267
+ /**
268
+ * Returns records without an audit row for the given action.
269
+ * @template {AuditedModelClass} MC
270
+ * @param {MC} modelClass - Model class to scope.
271
+ * @param {string} action - Audit action to exclude.
272
+ * @returns {import("../query/model-class-query.js").default<MC>} Query scoped to records without that audit action.
273
+ */
274
+ function withoutAudit(modelClass, action) {
275
+ const db = modelClass.connection()
276
+ const modelTableSql = db.quoteTable(modelClass.tableName())
277
+ const auditsTableSql = db.quoteTable("audits")
278
+ const auditActionsTableSql = db.quoteTable("audit_actions")
279
+ const modelPrimaryKeySql = `${modelTableSql}.${db.quoteColumn(modelClass.primaryKey())}`
280
+ const auditAuditableIdSql = `${auditsTableSql}.${db.quoteColumn("auditable_id")}`
281
+ const auditAuditableTypeSql = `${auditsTableSql}.${db.quoteColumn("auditable_type")}`
282
+ const auditActionIdSql = `${auditsTableSql}.${db.quoteColumn("audit_action_id")}`
283
+ const auditActionsIdSql = `${auditActionsTableSql}.${db.quoteColumn("id")}`
284
+ const auditActionsActionSql = `${auditActionsTableSql}.${db.quoteColumn("action")}`
285
+
286
+ return modelClass
287
+ .all()
288
+ .where(`
289
+ NOT EXISTS (
290
+ SELECT 1
291
+ FROM ${auditsTableSql}
292
+ INNER JOIN ${auditActionsTableSql}
293
+ ON ${auditActionsIdSql} = ${auditActionIdSql}
294
+ WHERE ${auditAuditableTypeSql} = ${db.quote(modelClass.getModelName())}
295
+ AND ${auditAuditableIdSql} = ${modelPrimaryKeySql}
296
+ AND ${auditActionsActionSql} = ${db.quote(normalizeAction(action))}
297
+ )
298
+ `)
299
+ }
300
+
301
+ /**
302
+ * Finds or creates a lookup row and returns its id.
303
+ * @param {object} args - Options object.
304
+ * @param {string} args.columnName - Lookup value column.
305
+ * @param {Date} args.currentDate - Timestamp to write when inserting.
306
+ * @param {import("../drivers/base.js").default} args.db - Database driver.
307
+ * @param {string} args.tableName - Lookup table.
308
+ * @param {string} args.value - Lookup value.
309
+ * @returns {Promise<number | string>} Lookup row id.
310
+ */
311
+ async function findOrCreateLookupId({columnName, currentDate, db, tableName, value}) {
312
+ await db.upsert({
313
+ tableName,
314
+ conflictColumns: [columnName],
315
+ updateColumns: [columnName],
316
+ data: {
317
+ id: new UUID(4).format(),
318
+ [columnName]: value,
319
+ created_at: currentDate,
320
+ updated_at: currentDate
321
+ }
322
+ })
323
+
324
+ const id = await findLookupId({columnName, db, tableName, value})
325
+
326
+ if (id === null) throw new Error(`Couldn't find ${tableName}.${columnName} after upsert`)
327
+
328
+ return id
329
+ }
330
+
331
+ /**
332
+ * Finds a lookup id by value.
333
+ * @param {object} args - Options object.
334
+ * @param {string} args.columnName - Lookup value column.
335
+ * @param {import("../drivers/base.js").default} args.db - Database driver.
336
+ * @param {string} args.tableName - Lookup table.
337
+ * @param {string} args.value - Lookup value.
338
+ * @returns {Promise<number | string | null>} Lookup row id or null.
339
+ */
340
+ async function findLookupId({columnName, db, tableName, value}) {
341
+ const rows = /** @type {Array<{id: number | string}>} */ (await db.query(`
342
+ SELECT ${db.quoteColumn("id")} AS id
343
+ FROM ${db.quoteTable(tableName)}
344
+ WHERE ${db.quoteColumn(columnName)} = ${db.quote(value)}
345
+ `))
346
+
347
+ if (rows[0]) return rows[0].id
348
+
349
+ return null
350
+ }
351
+
352
+ /**
353
+ * Normalizes an audit action.
354
+ * @param {string} action - Action name.
355
+ * @returns {string} Normalized action.
356
+ */
357
+ function normalizeAction(action) {
358
+ const normalizedAction = action.trim()
359
+
360
+ if (!normalizedAction) throw new Error("Audit action must be present")
361
+
362
+ return normalizedAction
363
+ }
364
+
365
+ /**
366
+ * Returns the callback map owned by a model class.
367
+ * @param {AuditedModelClass} modelClass - Model class.
368
+ * @returns {Record<string, AuditCallback[]>} Callback map.
369
+ */
370
+ function auditCallbacksForModelClass(modelClass) {
371
+ if (!Object.prototype.hasOwnProperty.call(modelClass, "_auditCallbacks")) {
372
+ modelClass._auditCallbacks = {}
373
+ }
374
+
375
+ const callbacks = modelClass._auditCallbacks
376
+
377
+ if (!callbacks) throw new Error(`Audit callbacks weren't initialized for ${modelClass.getModelName()}`)
378
+
379
+ return callbacks
380
+ }
381
+
382
+ /**
383
+ * Emits audit callbacks for a model/action pair.
384
+ * @param {AuditedModelClass} modelClass - Model class.
385
+ * @param {string} action - Audit action.
386
+ * @param {AuditEventPayload} payload - Event payload.
387
+ * @returns {Promise<void>}
388
+ */
389
+ async function emitAuditEvent(modelClass, action, payload) {
390
+ const callbacks = auditCallbacksForModelClass(modelClass)[action] || []
391
+
392
+ for (const callback of [...callbacks]) {
393
+ await callback(payload)
394
+ }
395
+ }
396
+
397
+ export {
398
+ captureCreateAuditChanges,
399
+ captureUpdateAuditChanges,
400
+ createAudit,
401
+ createCreateAudit,
402
+ createDestroyAudit,
403
+ createUpdateAudit,
404
+ registerAuditCallback,
405
+ registerAuditing,
406
+ withoutAudit
407
+ }
@@ -46,6 +46,7 @@ import singularizeModelName from "../../utils/singularize-model-name.js"
46
46
  import {defineModelScope} from "../../utils/model-scope.js"
47
47
  import { normalizeDateStringForWrite, normalizeDateValueForRead, normalizeDateValueForWrite } from "../datetime-storage.js"
48
48
  import {formatValue} from "../../utils/format-value.js"
49
+ import {captureCreateAuditChanges, captureUpdateAuditChanges, createAudit, createCreateAudit, createDestroyAudit, createUpdateAudit, registerAuditCallback, registerAuditing, withoutAudit} from "./auditing.js"
49
50
  import ValidatorsFormat from "./validators/format.js"
50
51
  import ValidatorsPresence from "./validators/presence.js"
51
52
  import ValidatorsUniqueness from "./validators/uniqueness.js"
@@ -247,6 +248,16 @@ class VelociousDatabaseRecord {
247
248
  * @type {boolean | undefined} */
248
249
  static _eagerLoadRecordMetadata
249
250
 
251
+ /**
252
+ * Narrows the runtime value to the documented type.
253
+ * @type {Record<string, import("./auditing.js").AuditCallback[]> | undefined} */
254
+ static _auditCallbacks
255
+
256
+ /**
257
+ * Narrows the runtime value to the documented type.
258
+ * @type {boolean | undefined} */
259
+ static _auditLifecycleCallbacksRegistered
260
+
250
261
  /**
251
262
  * Returns the model name, preferring an explicit `static modelName` declaration
252
263
  * over the JavaScript class `.name` property. This allows minified builds to
@@ -444,6 +455,16 @@ class VelociousDatabaseRecord {
444
455
  * @type {Record<string, ?>} */
445
456
  _changes = {}
446
457
 
458
+ /**
459
+ * Changes captured before a create audit is written.
460
+ * @type {import("./auditing.js").AuditChanges | undefined} */
461
+ _pendingCreateAuditChanges = undefined
462
+
463
+ /**
464
+ * Changes captured before an update audit is written.
465
+ * @type {import("./auditing.js").AuditChanges | undefined} */
466
+ _pendingUpdateAuditChanges = undefined
467
+
447
468
  /**
448
469
  * Attribute names explicitly assigned in the current update call.
449
470
  * @type {Set<string> | undefined}
@@ -613,6 +634,37 @@ class VelociousDatabaseRecord {
613
634
  VelociousDatabaseRecord.registerLifecycleCallback.call(this, "afterDestroy", /** @type {LifecycleCallbackType} */ (callback))
614
635
  }
615
636
 
637
+ /**
638
+ * Enables automatic create/update/destroy auditing for this model.
639
+ * @returns {void}
640
+ */
641
+ static audited() {
642
+ registerAuditing(this)
643
+ }
644
+
645
+ /**
646
+ * Registers a callback invoked after this model writes an audit row for the action.
647
+ * @template {typeof VelociousDatabaseRecord} MC
648
+ * @this {MC}
649
+ * @param {string} action - Audit action name.
650
+ * @param {import("./auditing.js").AuditCallback} callback - Callback to run after audit creation.
651
+ * @returns {() => void} Unsubscribe function.
652
+ */
653
+ static onAudit(action, callback) {
654
+ return registerAuditCallback(this, action, callback)
655
+ }
656
+
657
+ /**
658
+ * Returns records that do not have an audit row for the given action.
659
+ * @template {typeof VelociousDatabaseRecord} MC
660
+ * @this {MC}
661
+ * @param {string} action - Audit action name.
662
+ * @returns {ModelClassQuery<MC>} Query scoped to records without that audit action.
663
+ */
664
+ static withoutAudit(action) {
665
+ return withoutAudit(this, action)
666
+ }
667
+
616
668
  /**
617
669
  * Runs get validator type.
618
670
  * @param {string} validatorName - Validator name.
@@ -3597,6 +3649,55 @@ class VelociousDatabaseRecord {
3597
3649
  await this._runLifecycleCallbacks("afterDestroy")
3598
3650
  }
3599
3651
 
3652
+ /**
3653
+ * Stores an audit row for this record.
3654
+ * @param {import("./auditing.js").CreateAuditArgs} args - Audit row options.
3655
+ * @returns {Promise<number | string>} Created audit row id.
3656
+ */
3657
+ async createAudit(args) {
3658
+ return await createAudit(this, args)
3659
+ }
3660
+
3661
+ /**
3662
+ * Captures create changes before persistence clears the change set.
3663
+ * @returns {void}
3664
+ */
3665
+ captureCreateAuditChanges() {
3666
+ captureCreateAuditChanges(this)
3667
+ }
3668
+
3669
+ /**
3670
+ * Writes the create audit row.
3671
+ * @returns {Promise<void>}
3672
+ */
3673
+ async createCreateAudit() {
3674
+ await createCreateAudit(this)
3675
+ }
3676
+
3677
+ /**
3678
+ * Captures update changes before persistence clears the change set.
3679
+ * @returns {void}
3680
+ */
3681
+ captureUpdateAuditChanges() {
3682
+ captureUpdateAuditChanges(this)
3683
+ }
3684
+
3685
+ /**
3686
+ * Writes the update audit row.
3687
+ * @returns {Promise<void>}
3688
+ */
3689
+ async createUpdateAudit() {
3690
+ await createUpdateAudit(this)
3691
+ }
3692
+
3693
+ /**
3694
+ * Writes the destroy audit row.
3695
+ * @returns {Promise<void>}
3696
+ */
3697
+ async createDestroyAudit() {
3698
+ await createDestroyAudit(this)
3699
+ }
3700
+
3600
3701
  /**
3601
3702
  * Runs run lifecycle callbacks.
3602
3703
  * @param {"afterCreate" | "afterDestroy" | "afterSave" | "afterUpdate" | "beforeCreate" | "beforeDestroy" | "beforeSave" | "beforeUpdate" | "beforeValidation"} callbackName - Callback type.
@@ -23,9 +23,10 @@ export default class Tenant {
23
23
  * the caller wiring up connections. Already-checked-out connections are reused, so nesting
24
24
  * `Tenant.with` calls does not open redundant connections. The callback receives the active
25
25
  * connections keyed by identifier, the same as `ensureConnections`.
26
+ * @template T
26
27
  * @param {object} tenant Descriptor understood by the app's tenantDatabaseResolver.
27
- * @param {(connections: Record<string, import("../database/drivers/base.js").default>) => Promise<?>} callback
28
- * @returns {Promise<?>}
28
+ * @param {(connections: Record<string, import("../database/drivers/base.js").default>) => Promise<T>} callback
29
+ * @returns {Promise<T>}
29
30
  */
30
31
  static async with(tenant, callback) {
31
32
  const configuration = Current.configuration()