velocious 1.0.506 → 1.0.508

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.
@@ -34,12 +34,324 @@ import UUID from "pure-uuid"
34
34
  * AuditedModelClass type.
35
35
  * @typedef {typeof import("./index.js").default & {
36
36
  * _auditCallbacks?: Record<string, AuditCallback[]>,
37
- * _auditLifecycleCallbacksRegistered?: boolean
37
+ * _auditLifecycleCallbacksRegistered?: boolean,
38
+ * _auditTableResolved?: boolean,
39
+ * _auditTableData?: AuditTableData
38
40
  * }} AuditedModelClass
39
41
  */
40
42
 
41
43
  /**
42
- * Adds automatic create/update/destroy auditing callbacks to a model class.
44
+ * AuditTableData type.
45
+ * @typedef {object} AuditTableData
46
+ * @property {boolean} dedicated - Whether a dedicated audit table exists.
47
+ * @property {string} tableName - Name of the audit table.
48
+ * @property {string} foreignKey - Column name that references the audited model.
49
+ * @property {typeof import("./index.js").default} auditClass - The audit model class to use.
50
+ */
51
+
52
+ /** @type {Map<string, boolean>} */
53
+ const dedicatedTableCache = new Map()
54
+
55
+ /** @type {Map<string, typeof import("./index.js").default>} */
56
+ const auditClassCache = new Map()
57
+
58
+ const generatedAuditRelationships = new WeakSet()
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Global event bus (like ActiveRecordAuditable::Events)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** @type {Record<string, Record<string, Array<(args: AuditEventPayload) => void>>>} */
65
+ let globalEventConnections = {}
66
+
67
+ /**
68
+ * Global audit event bus matching ActiveRecordAuditable::Events.
69
+ * @typedef {object} AuditEventsType
70
+ * @property {(type: string, action: string, args: AuditEventPayload) => void} call - Fire all callbacks for a model type + action.
71
+ * @property {(type: string, action: string, callback: (args: AuditEventPayload) => void) => () => void} connect - Register a callback for a model type + action. Returns an unsubscribe function.
72
+ * @property {() => void} reset - Clear all registered callbacks.
73
+ */
74
+
75
+ /** @type {AuditEventsType} */
76
+ const AuditEvents = {
77
+ /** Fire all registered callbacks for a model type + action. @param {string} type @param {string} action @param {AuditEventPayload} args */
78
+ call(type, action, args) {
79
+ const actions = globalEventConnections[type] || {}
80
+ const callbacks = actions[action] || []
81
+
82
+ for (const callback of [...callbacks]) {
83
+ callback(args)
84
+ }
85
+ },
86
+
87
+ /** Register a callback for a model type + action. Returns an unsubscribe function. @param {string} type @param {string} action @param {(args: AuditEventPayload) => void} callback @returns {() => void} */
88
+ connect(type, action, callback) {
89
+ if (!globalEventConnections[type]) {
90
+ globalEventConnections[type] = {}
91
+ }
92
+
93
+ if (!globalEventConnections[type][action]) {
94
+ globalEventConnections[type][action] = []
95
+ }
96
+
97
+ globalEventConnections[type][action].push(callback)
98
+
99
+ return () => {
100
+ const list = globalEventConnections[type]?.[action]
101
+
102
+ if (list) {
103
+ const index = list.indexOf(callback)
104
+
105
+ if (index >= 0) {
106
+ list.splice(index, 1)
107
+ }
108
+ }
109
+ }
110
+ },
111
+
112
+ /** Clear all registered callbacks. @returns {void} */
113
+ reset() {
114
+ globalEventConnections = {}
115
+ }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Table detection
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Returns the dedicated audit table name for a model class.
124
+ * @param {AuditedModelClass} modelClass - Audited model class.
125
+ * @returns {string} e.g. "project_audits" for a "projects" table.
126
+ */
127
+ function dedicatedAuditTableName(modelClass) {
128
+ const table = modelClass.tableName()
129
+
130
+ if (table.endsWith("s")) {
131
+ return `${table.slice(0, -1)}_audits`
132
+ }
133
+
134
+ return `${table}_audits`
135
+ }
136
+
137
+ /**
138
+ * Resolves audit table data for a model class. Cached per model.
139
+ * Called lazily on first createAudit / withoutAudit / relationship usage.
140
+ * @param {AuditedModelClass} modelClass - Audited model class.
141
+ * @returns {Promise<AuditTableData>} Resolved audit table metadata.
142
+ */
143
+ async function resolveAuditTableData(modelClass) {
144
+ if (modelClass._auditTableResolved && modelClass._auditTableData) {
145
+ return modelClass._auditTableData
146
+ }
147
+
148
+ const tableData = await buildAuditTableData(modelClass)
149
+ const configuration = modelClass._getConfiguration()
150
+
151
+ tableData.auditClass.registerRecordClass({configuration})
152
+ await tableData.auditClass.initializeRecord({configuration})
153
+
154
+ modelClass._auditTableData = tableData
155
+ modelClass._auditTableResolved = true
156
+
157
+ registerAuditRelationship(modelClass, tableData)
158
+
159
+ return tableData
160
+ }
161
+
162
+ /**
163
+ * Builds audit table metadata for a model class. Prefers the consumer's
164
+ * registered Audit model for shared tables; falls back to a framework-owned
165
+ * dynamic class.
166
+ * @param {AuditedModelClass} modelClass - Audited model class.
167
+ * @returns {Promise<AuditTableData>} Audit table metadata.
168
+ */
169
+ async function buildAuditTableData(modelClass) {
170
+ const dedicatedTable = dedicatedAuditTableName(modelClass)
171
+ const dedicatedExists = await dedicatedTableExistsForConnection(modelClass, dedicatedTable)
172
+
173
+ if (dedicatedExists) {
174
+ const auditClass = dedicatedAuditClass(modelClass, dedicatedTable)
175
+ const modelKey = modelParamKey(modelClass)
176
+
177
+ return {
178
+ auditClass,
179
+ dedicated: true,
180
+ foreignKey: `${modelKey}_id`,
181
+ tableName: dedicatedTable
182
+ }
183
+ }
184
+
185
+ const configuration = modelClass._getConfiguration()
186
+
187
+ const consumerAuditClass = configuration.getModelClasses().Audit
188
+
189
+ if (consumerAuditClass) {
190
+ return {
191
+ auditClass: consumerAuditClass,
192
+ dedicated: false,
193
+ foreignKey: "auditable_id",
194
+ tableName: "audits"
195
+ }
196
+ }
197
+
198
+ return {
199
+ auditClass: cachedSharedAuditClass(modelClass),
200
+ dedicated: false,
201
+ foreignKey: "auditable_id",
202
+ tableName: "audits"
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Checks whether a dedicated audit table exists for a model's connection.
208
+ * @param {AuditedModelClass} modelClass - Audited model class.
209
+ * @param {string} tableName - Dedicated audit table name to check.
210
+ * @returns {Promise<boolean>} Whether the table exists.
211
+ */
212
+ async function dedicatedTableExistsForConnection(modelClass, tableName) {
213
+ const databaseIdentifier = modelClass.getDatabaseIdentifier()
214
+ const cacheKey = `${databaseIdentifier}:${tableName}`
215
+ const cached = dedicatedTableCache.get(cacheKey)
216
+
217
+ if (typeof cached === "boolean") {
218
+ return cached
219
+ }
220
+
221
+ const connection = modelClass.connection()
222
+ const table = await connection.getTableByName(tableName, {throwError: false})
223
+ const exists = Boolean(table)
224
+
225
+ dedicatedTableCache.set(cacheKey, exists)
226
+
227
+ return exists
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Dynamic audit classes
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Returns a framework-owned shared Audit class for the `audits` table.
236
+ * This is only used when no consumer-registered Audit model exists.
237
+ * @param {AuditedModelClass} modelClass - Any audited model class (used to locate DatabaseRecord).
238
+ * @returns {typeof import("./index.js").default} Shared Audit model class.
239
+ */
240
+ function sharedAuditClass(modelClass) {
241
+ const dbRecordClass = findDatabaseRecordClass(modelClass)
242
+
243
+ /**
244
+ * Framework-owned Audit model for the shared `audits` table.
245
+ */
246
+ class Audit extends dbRecordClass {
247
+ /** Returns the backing table name. @returns {string} */
248
+ static tableName() {
249
+ return "audits"
250
+ }
251
+ }
252
+
253
+ Object.defineProperty(Audit, "modelName", {value: "Audit", writable: false})
254
+ applyAuditClassDatabaseRouting(modelClass, Audit)
255
+
256
+ return /** @type {typeof import("./index.js").default} */ (Audit)
257
+ }
258
+
259
+ /**
260
+ * Returns the cached framework-owned shared Audit class for a database.
261
+ * @param {AuditedModelClass} modelClass - Any audited model class.
262
+ * @returns {typeof import("./index.js").default} Shared Audit model class.
263
+ */
264
+ function cachedSharedAuditClass(modelClass) {
265
+ const routingKey = modelClass.hasTenantDatabaseIdentifierResolver()
266
+ ? `tenant:${modelClass.getModelName()}`
267
+ : `database:${modelClass.getConfiguredDatabaseIdentifier()}`
268
+ const cacheKey = `shared:${routingKey}`
269
+ let auditClass = auditClassCache.get(cacheKey)
270
+
271
+ if (!auditClass) {
272
+ auditClass = sharedAuditClass(modelClass)
273
+ auditClassCache.set(cacheKey, auditClass)
274
+ }
275
+
276
+ return auditClass
277
+ }
278
+
279
+ /**
280
+ * Returns a dynamic per-model audit class for a dedicated table.
281
+ * @param {AuditedModelClass} modelClass - Audited model class.
282
+ * @param {string} tableName - Dedicated audit table name (e.g. "project_audits").
283
+ * @returns {typeof import("./index.js").default} Dedicated audit model class.
284
+ */
285
+ function dedicatedAuditClass(modelClass, tableName) {
286
+ const dbRecordClass = findDatabaseRecordClass(modelClass)
287
+ const modelName = modelClass.getModelName()
288
+ const modelKey = modelParamKey(modelClass)
289
+
290
+ /**
291
+ * Framework-owned per-model Audit class.
292
+ */
293
+ class ModelAudit extends dbRecordClass {
294
+ /** Returns the backing table name. @returns {string} */
295
+ static tableName() {
296
+ return tableName
297
+ }
298
+ }
299
+
300
+ Object.defineProperty(ModelAudit, "modelName", {value: `${modelName}Audit`, writable: false})
301
+ applyAuditClassDatabaseRouting(modelClass, ModelAudit)
302
+ ModelAudit.belongsTo(modelKey, {className: modelName})
303
+
304
+ return /** @type {typeof import("./index.js").default} */ (ModelAudit)
305
+ }
306
+
307
+ /**
308
+ * Makes framework-owned audit classes read the same database as the audited model.
309
+ * @param {AuditedModelClass} modelClass - Audited source model class.
310
+ * @param {typeof import("./index.js").default} auditClass - Generated audit class.
311
+ * @returns {void}
312
+ */
313
+ function applyAuditClassDatabaseRouting(modelClass, auditClass) {
314
+ auditClass.setDatabaseIdentifier(modelClass.getConfiguredDatabaseIdentifier())
315
+
316
+ if (modelClass.hasTenantDatabaseIdentifierResolver()) {
317
+ auditClass.switchesTenantDatabase(({tenant}) => modelClass.getTenantDatabaseIdentifier(tenant))
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Walks the prototype chain to find the root DatabaseRecord class.
323
+ * @param {AuditedModelClass} modelClass - Audited model class.
324
+ * @returns {typeof import("./index.js").default} Root DatabaseRecord class.
325
+ */
326
+ function findDatabaseRecordClass(modelClass) {
327
+ let recordClass = Object.getPrototypeOf(modelClass)
328
+
329
+ while (recordClass && Object.getPrototypeOf(recordClass) !== Function.prototype) {
330
+ recordClass = Object.getPrototypeOf(recordClass)
331
+ }
332
+
333
+ return /** @type {typeof import("./index.js").default} */ (recordClass)
334
+ }
335
+
336
+ /**
337
+ * Returns the parameter-key name for a model class.
338
+ * @param {AuditedModelClass} modelClass - Audited model class.
339
+ * @returns {string} e.g. "project" for "Project".
340
+ */
341
+ function modelParamKey(modelClass) {
342
+ const name = modelClass.getModelName()
343
+
344
+ return name.charAt(0).toLowerCase() + name.slice(1)
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Registration (sync — callbacks only; table detection deferred)
349
+ // ---------------------------------------------------------------------------
350
+
351
+ /**
352
+ * Registers lifecycle callbacks for automatic create/update/destroy auditing.
353
+ * Table detection and relationship registration happen lazily on first usage.
354
+ * Called synchronously from Model.audited() at module-load time.
43
355
  * @param {AuditedModelClass} modelClass - Model class to audit.
44
356
  * @returns {void}
45
357
  */
@@ -49,6 +361,7 @@ function registerAuditing(modelClass) {
49
361
  }
50
362
 
51
363
  modelClass._auditLifecycleCallbacksRegistered = true
364
+ registerAuditRelationship(modelClass)
52
365
  modelClass.beforeCreate("captureCreateAuditChanges")
53
366
  modelClass.afterCreate("createCreateAudit")
54
367
  modelClass.beforeUpdate("captureUpdateAuditChanges")
@@ -57,63 +370,117 @@ function registerAuditing(modelClass) {
57
370
  }
58
371
 
59
372
  /**
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.
373
+ * Initializes audit metadata for audited model classes.
374
+ * @param {typeof import("./index.js").default} modelClass - Model class to initialize.
375
+ * @param {{resolveTableData?: boolean}} [args] - Initialization options.
376
+ * @returns {Promise<void>}
65
377
  */
66
- function registerAuditCallback(modelClass, action, callback) {
67
- const normalizedAction = normalizeAction(action)
68
- const callbacks = auditCallbacksForModelClass(modelClass)
378
+ async function initializeAuditing(modelClass, args = {}) {
379
+ const {resolveTableData = false} = args
380
+ const auditedModelClass = /** @type {AuditedModelClass} */ (modelClass)
69
381
 
70
- if (!callbacks[normalizedAction]) {
71
- callbacks[normalizedAction] = []
382
+ if (!auditedModelClass._auditLifecycleCallbacksRegistered) {
383
+ return
72
384
  }
73
385
 
74
- callbacks[normalizedAction].push(callback)
386
+ registerAuditRelationship(auditedModelClass)
75
387
 
76
- return () => {
77
- const actionCallbacks = callbacks[normalizedAction]
78
- const index = actionCallbacks.indexOf(callback)
388
+ if (resolveTableData && auditedModelClass._initialized && canResolveAuditTableData(auditedModelClass)) {
389
+ await resolveAuditTableData(auditedModelClass)
390
+ }
391
+ }
79
392
 
80
- if (index >= 0) {
81
- actionCallbacks.splice(index, 1)
82
- }
393
+ /**
394
+ * Resolves audit metadata after application and package model classes are registered.
395
+ * @param {import("../../configuration.js").default} configuration - Configuration whose models should be finalized.
396
+ * @returns {Promise<void>}
397
+ */
398
+ async function initializeAuditedModelRelationships(configuration) {
399
+ for (const modelClass of Object.values(configuration.getModelClasses())) {
400
+ await initializeAuditing(modelClass, {resolveTableData: true})
83
401
  }
84
402
  }
85
403
 
86
404
  /**
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.
405
+ * Registers the audits relationship without forcing audit table detection.
406
+ * @param {AuditedModelClass} modelClass - Model class to audit.
407
+ * @param {AuditTableData} [tableData] - Resolved audit table data when available.
408
+ * @returns {void}
90
409
  */
91
- function auditChangesForCurrentChanges(record) {
92
- const changes = record.changes()
93
- /** @type {AuditChanges} */
94
- const auditedChanges = {}
95
- const columnNameToAttributeName = record.getModelClass().getColumnNameToAttributeNameMap()
410
+ function registerAuditRelationship(modelClass, tableData = defaultAuditRelationshipTableData(modelClass)) {
411
+ if (modelClass._relationshipExists("audits")) {
412
+ const relationship = modelClass.getRelationshipByName("audits")
413
+
414
+ if (generatedAuditRelationships.has(relationship)) {
415
+ relationship.className = undefined
416
+ relationship.klass = tableData.auditClass
417
+ relationship.foreignKey = tableData.foreignKey
418
+ relationship._explicitForeignKey = tableData.foreignKey
419
+ relationship._polymorphic = !tableData.dedicated
420
+ relationship._polymorphicTypeColumn = undefined
421
+ }
96
422
 
97
- for (const [attributeName, change] of Object.entries(changes)) {
98
- auditedChanges[columnNameToAttributeName[attributeName] || attributeName] = change[1]
423
+ return
99
424
  }
100
425
 
101
- return auditedChanges
426
+ modelClass.hasMany("audits", auditRelationshipScope, {
427
+ foreignKey: tableData.foreignKey,
428
+ klass: tableData.auditClass,
429
+ polymorphic: !tableData.dedicated
430
+ })
431
+ generatedAuditRelationships.add(modelClass.getRelationshipByName("audits"))
102
432
  }
103
433
 
104
434
  /**
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.
435
+ * Returns unresolved shared-audit defaults for early relationship registration.
436
+ * @param {AuditedModelClass} modelClass - Model class to audit.
437
+ * @returns {AuditTableData} Shared audit relationship defaults.
108
438
  */
109
- function auditChangesForDestroy(record) {
110
- return {...record.attributes()}
439
+ function defaultAuditRelationshipTableData(modelClass) {
440
+ return {
441
+ auditClass: cachedSharedAuditClass(modelClass),
442
+ dedicated: false,
443
+ foreignKey: "auditable_id",
444
+ tableName: "audits"
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Applies default audit ordering to generated audit relationships.
450
+ * @param {import("../query/model-class-query.js").default<typeof import("./index.js").default>} query - Audit query.
451
+ * @returns {import("../query/model-class-query.js").default<typeof import("./index.js").default>} Ordered audit query.
452
+ */
453
+ function auditRelationshipScope(query) {
454
+ return query.order({column: "created_at", direction: "DESC"})
455
+ }
456
+
457
+ /**
458
+ * Checks whether the current tenant context can resolve audit table data.
459
+ * @param {AuditedModelClass} modelClass - Model class to inspect.
460
+ * @returns {boolean} Whether audit table data can be resolved in the current scope.
461
+ */
462
+ function canResolveAuditTableData(modelClass) {
463
+ if (!modelClass.hasTenantDatabaseIdentifierResolver()) {
464
+ return true
465
+ }
466
+
467
+ const tenantDatabaseIdentifier = modelClass.getTenantDatabaseIdentifier()
468
+
469
+ if (!tenantDatabaseIdentifier) {
470
+ return false
471
+ }
472
+
473
+ return modelClass._getConfiguration().isDatabaseIdentifierActive(tenantDatabaseIdentifier)
111
474
  }
112
475
 
476
+ // ---------------------------------------------------------------------------
477
+ // Creating audits
478
+ // ---------------------------------------------------------------------------
479
+
113
480
  /**
114
481
  * Creates an audit row for a record.
115
482
  * @param {import("./index.js").default} record - Record to audit.
116
- * @param {CreateAuditArgs} args - Audit row options.
483
+ * @param {CreateAuditArgs} args - Audit row options (action, auditedChanges, params).
117
484
  * @returns {Promise<number | string>} Created audit row id.
118
485
  */
119
486
  async function createAudit(record, args) {
@@ -126,85 +493,100 @@ async function createAudit(record, args) {
126
493
 
127
494
  /**
128
495
  * Creates an audit row using the current model database connection.
496
+ * Routes to shared table or dedicated table based on resolved audit table data.
129
497
  * @param {import("./index.js").default} record - Record to audit.
130
- * @param {CreateAuditArgs} args - Audit row options.
498
+ * @param {CreateAuditArgs} args - Audit row options (action, auditedChanges, params).
131
499
  * @param {AuditedModelClass} modelClass - Audited model class.
132
500
  * @returns {Promise<number | string>} Created audit row id.
133
501
  */
134
502
  async function createAuditWithCurrentConnection(record, args, modelClass) {
135
503
  if (!record.isPersisted()) throw new Error(`Cannot audit unpersisted ${modelClass.getModelName()} record`)
136
504
 
505
+ const tableData = await resolveAuditTableData(modelClass)
137
506
  const action = normalizeAction(args.action)
138
507
  const auditedChanges = args.auditedChanges === undefined ? null : args.auditedChanges
139
508
  const params = args.params === undefined ? null : args.params
140
509
  const db = modelClass.connection()
141
510
  const currentDate = new Date()
511
+
142
512
  const auditActionId = await findOrCreateLookupId({
143
513
  columnName: "action",
514
+ currentDate,
144
515
  db,
145
516
  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
517
+ value: action
155
518
  })
519
+
156
520
  const auditId = new UUID(4).format()
157
521
 
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
522
+ if (tableData.dedicated) {
523
+ const modelKey = modelParamKey(modelClass)
524
+
525
+ await db.query(db.insertSql({
526
+ returnLastInsertedColumnNames: ["id"],
527
+ tableName: tableData.tableName,
528
+ data: {
529
+ id: auditId,
530
+ [`${modelKey}_id`]: record.id(),
531
+ audit_action_id: auditActionId,
532
+ audited_changes: auditedChanges,
533
+ params,
534
+ created_at: currentDate,
535
+ updated_at: currentDate
536
+ }
537
+ }))
538
+ } else {
539
+ const auditAuditableTypeId = await findOrCreateLookupId({
540
+ columnName: "name",
541
+ currentDate,
542
+ db,
543
+ tableName: "audit_auditable_types",
544
+ value: modelClass.getModelName()
545
+ })
546
+
547
+ await db.query(db.insertSql({
548
+ returnLastInsertedColumnNames: ["id"],
549
+ tableName: "audits",
550
+ data: {
551
+ id: auditId,
552
+ audit_action_id: auditActionId,
553
+ audit_auditable_type_id: auditAuditableTypeId,
554
+ auditable_id: record.id(),
555
+ auditable_type: modelClass.getModelName(),
556
+ audited_changes: auditedChanges,
557
+ params,
558
+ created_at: currentDate,
559
+ updated_at: currentDate
560
+ }
561
+ }))
562
+ }
174
563
 
175
564
  await emitAuditEvent(modelClass, action, {
176
565
  action,
177
- auditId: insertedAuditId,
566
+ auditId,
178
567
  auditedChanges,
179
568
  params,
180
569
  record
181
570
  })
182
571
 
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
572
+ AuditEvents.call(modelClass.getModelName(), action, {
573
+ action,
574
+ auditId,
575
+ auditedChanges,
576
+ params,
577
+ record
578
+ })
201
579
 
202
- return null
580
+ return auditId
203
581
  }
204
582
 
583
+ // ---------------------------------------------------------------------------
584
+ // Lifecycle callbacks
585
+ // ---------------------------------------------------------------------------
586
+
205
587
  /**
206
- * Captures create changes on a model instance.
207
- * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to capture.
588
+ * Captures create changes before persistence clears the change set.
589
+ * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record whose pending changes should be captured.
208
590
  * @returns {void}
209
591
  */
210
592
  function captureCreateAuditChanges(record) {
@@ -212,7 +594,7 @@ function captureCreateAuditChanges(record) {
212
594
  }
213
595
 
214
596
  /**
215
- * Creates the create audit row for a model instance.
597
+ * Writes the create audit row for a model instance.
216
598
  * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to audit.
217
599
  * @returns {Promise<void>}
218
600
  */
@@ -226,8 +608,8 @@ async function createCreateAudit(record) {
226
608
  }
227
609
 
228
610
  /**
229
- * Captures update changes on a model instance.
230
- * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to capture.
611
+ * Captures update changes before persistence clears the change set.
612
+ * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record whose pending changes should be captured.
231
613
  * @returns {void}
232
614
  */
233
615
  function captureUpdateAuditChanges(record) {
@@ -235,7 +617,7 @@ function captureUpdateAuditChanges(record) {
235
617
  }
236
618
 
237
619
  /**
238
- * Creates the update audit row for a model instance.
620
+ * Writes the update audit row for a model instance.
239
621
  * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to audit.
240
622
  * @returns {Promise<void>}
241
623
  */
@@ -253,7 +635,7 @@ async function createUpdateAudit(record) {
253
635
  }
254
636
 
255
637
  /**
256
- * Creates the destroy audit row for a model instance.
638
+ * Writes the destroy audit row for a model instance.
257
639
  * @param {import("./index.js").default} record - Record to audit.
258
640
  * @returns {Promise<void>}
259
641
  */
@@ -264,8 +646,45 @@ async function createDestroyAudit(record) {
264
646
  })
265
647
  }
266
648
 
649
+ // ---------------------------------------------------------------------------
650
+ // Changes helpers
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /**
654
+ * Captures the new values for fields changed on a record.
655
+ * @param {import("./index.js").default} record - Record whose pending changes should be captured.
656
+ * @returns {AuditChanges} New values keyed by attribute name.
657
+ */
658
+ function auditChangesForCurrentChanges(record) {
659
+ const changes = record.changes()
660
+ /** @type {AuditChanges} */
661
+ const auditedChanges = {}
662
+ const columnNameToAttributeName = record.getModelClass().getColumnNameToAttributeNameMap()
663
+
664
+ for (const [attributeName, change] of Object.entries(changes)) {
665
+ auditedChanges[columnNameToAttributeName[attributeName] || attributeName] = change[1]
666
+ }
667
+
668
+ return auditedChanges
669
+ }
670
+
671
+ /**
672
+ * Captures the current attributes for a destroy audit.
673
+ * @param {import("./index.js").default} record - Record being destroyed.
674
+ * @returns {AuditChanges} Current attributes keyed by attribute name.
675
+ */
676
+ function auditChangesForDestroy(record) {
677
+ return {...record.attributes()}
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // withoutAudit
682
+ // ---------------------------------------------------------------------------
683
+
267
684
  /**
268
685
  * Returns records without an audit row for the given action.
686
+ * Uses shared-table defaults when table data is not yet resolved;
687
+ * switches to the dedicated table path once resolved.
269
688
  * @template {AuditedModelClass} MC
270
689
  * @param {MC} modelClass - Model class to scope.
271
690
  * @param {string} action - Audit action to exclude.
@@ -274,14 +693,36 @@ async function createDestroyAudit(record) {
274
693
  function withoutAudit(modelClass, action) {
275
694
  const db = modelClass.connection()
276
695
  const modelTableSql = db.quoteTable(modelClass.tableName())
277
- const auditsTableSql = db.quoteTable("audits")
278
696
  const auditActionsTableSql = db.quoteTable("audit_actions")
279
697
  const modelPrimaryKeySql = `${modelTableSql}.${db.quoteColumn(modelClass.primaryKey())}`
698
+ const auditActionsIdSql = `${auditActionsTableSql}.${db.quoteColumn("id")}`
699
+ const auditActionsActionSql = `${auditActionsTableSql}.${db.quoteColumn("action")}`
700
+
701
+ if (modelClass._auditTableResolved && modelClass._auditTableData?.dedicated) {
702
+ const tableData = modelClass._auditTableData
703
+ const modelKey = modelParamKey(modelClass)
704
+ const auditsTableSql = db.quoteTable(tableData.tableName)
705
+ const auditActionIdSql = `${auditsTableSql}.${db.quoteColumn("audit_action_id")}`
706
+ const modelIdSql = `${auditsTableSql}.${db.quoteColumn(`${modelKey}_id`)}`
707
+
708
+ return modelClass
709
+ .all()
710
+ .where(`
711
+ NOT EXISTS (
712
+ SELECT 1
713
+ FROM ${auditsTableSql}
714
+ INNER JOIN ${auditActionsTableSql}
715
+ ON ${auditActionsIdSql} = ${auditActionIdSql}
716
+ WHERE ${modelIdSql} = ${modelPrimaryKeySql}
717
+ AND ${auditActionsActionSql} = ${db.quote(normalizeAction(action))}
718
+ )
719
+ `)
720
+ }
721
+
722
+ const auditsTableSql = db.quoteTable("audits")
280
723
  const auditAuditableIdSql = `${auditsTableSql}.${db.quoteColumn("auditable_id")}`
281
724
  const auditAuditableTypeSql = `${auditsTableSql}.${db.quoteColumn("auditable_type")}`
282
725
  const auditActionIdSql = `${auditsTableSql}.${db.quoteColumn("audit_action_id")}`
283
- const auditActionsIdSql = `${auditActionsTableSql}.${db.quoteColumn("id")}`
284
- const auditActionsActionSql = `${auditActionsTableSql}.${db.quoteColumn("action")}`
285
726
 
286
727
  return modelClass
287
728
  .all()
@@ -298,13 +739,80 @@ function withoutAudit(modelClass, action) {
298
739
  `)
299
740
  }
300
741
 
742
+ // ---------------------------------------------------------------------------
743
+ // Event callbacks
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Registers a per-model callback fired after an audit row is created.
748
+ * @param {AuditedModelClass} modelClass - Model class to observe.
749
+ * @param {string} action - Audit action name (e.g. "create").
750
+ * @param {AuditCallback} callback - Callback invoked after matching audit rows are created.
751
+ * @returns {() => void} Unsubscribe function.
752
+ */
753
+ function registerAuditCallback(modelClass, action, callback) {
754
+ const normalizedAction = normalizeAction(action)
755
+ const callbacks = auditCallbacksForModelClass(modelClass)
756
+
757
+ if (!callbacks[normalizedAction]) {
758
+ callbacks[normalizedAction] = []
759
+ }
760
+
761
+ callbacks[normalizedAction].push(callback)
762
+
763
+ return () => {
764
+ const actionCallbacks = callbacks[normalizedAction]
765
+ const index = actionCallbacks.indexOf(callback)
766
+
767
+ if (index >= 0) {
768
+ actionCallbacks.splice(index, 1)
769
+ }
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Emits per-model audit callbacks for a model/action pair.
775
+ * @param {AuditedModelClass} modelClass - Model class.
776
+ * @param {string} action - Audit action.
777
+ * @param {AuditEventPayload} payload - Event payload.
778
+ * @returns {Promise<void>}
779
+ */
780
+ async function emitAuditEvent(modelClass, action, payload) {
781
+ const callbacks = auditCallbacksForModelClass(modelClass)[action] || []
782
+
783
+ for (const callback of [...callbacks]) {
784
+ await callback(payload)
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Returns the per-model callback map.
790
+ * @param {AuditedModelClass} modelClass - Model class.
791
+ * @returns {Record<string, AuditCallback[]>} Callback map keyed by action.
792
+ */
793
+ function auditCallbacksForModelClass(modelClass) {
794
+ if (!Object.prototype.hasOwnProperty.call(modelClass, "_auditCallbacks")) {
795
+ modelClass._auditCallbacks = {}
796
+ }
797
+
798
+ const callbacks = modelClass._auditCallbacks
799
+
800
+ if (!callbacks) throw new Error(`Audit callbacks weren't initialized for ${modelClass.getModelName()}`)
801
+
802
+ return callbacks
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Lookup helpers
807
+ // ---------------------------------------------------------------------------
808
+
301
809
  /**
302
810
  * Finds or creates a lookup row and returns its id.
303
811
  * @param {object} args - Options object.
304
- * @param {string} args.columnName - Lookup value column.
812
+ * @param {string} args.columnName - Lookup value column name.
305
813
  * @param {Date} args.currentDate - Timestamp to write when inserting.
306
814
  * @param {import("../drivers/base.js").default} args.db - Database driver.
307
- * @param {string} args.tableName - Lookup table.
815
+ * @param {string} args.tableName - Lookup table name.
308
816
  * @param {string} args.value - Lookup value.
309
817
  * @returns {Promise<number | string>} Lookup row id.
310
818
  */
@@ -331,9 +839,9 @@ async function findOrCreateLookupId({columnName, currentDate, db, tableName, val
331
839
  /**
332
840
  * Finds a lookup id by value.
333
841
  * @param {object} args - Options object.
334
- * @param {string} args.columnName - Lookup value column.
842
+ * @param {string} args.columnName - Lookup value column name.
335
843
  * @param {import("../drivers/base.js").default} args.db - Database driver.
336
- * @param {string} args.tableName - Lookup table.
844
+ * @param {string} args.tableName - Lookup table name.
337
845
  * @param {string} args.value - Lookup value.
338
846
  * @returns {Promise<number | string | null>} Lookup row id or null.
339
847
  */
@@ -350,9 +858,9 @@ async function findLookupId({columnName, db, tableName, value}) {
350
858
  }
351
859
 
352
860
  /**
353
- * Normalizes an audit action.
861
+ * Normalizes an audit action string.
354
862
  * @param {string} action - Action name.
355
- * @returns {string} Normalized action.
863
+ * @returns {string} Trimmed, non-empty action name.
356
864
  */
357
865
  function normalizeAction(action) {
358
866
  const normalizedAction = action.trim()
@@ -362,45 +870,76 @@ function normalizeAction(action) {
362
870
  return normalizedAction
363
871
  }
364
872
 
873
+ // ---------------------------------------------------------------------------
874
+ // Migration helpers
875
+ // ---------------------------------------------------------------------------
876
+
365
877
  /**
366
- * Returns the callback map owned by a model class.
367
- * @param {AuditedModelClass} modelClass - Model class.
368
- * @returns {Record<string, AuditCallback[]>} Callback map.
878
+ * Creates the shared audit tables migration up/down callbacks for use inside
879
+ * a Migration class. The `table` parameter is a Migration instance.
880
+ * @param {{id?: {type: string}}} [options] - ID column options.
881
+ * @returns {{down: (table: import("../migration/index.js").default) => Promise<void>, up: (table: import("../migration/index.js").default) => Promise<void>}}
369
882
  */
370
- function auditCallbacksForModelClass(modelClass) {
371
- if (!Object.prototype.hasOwnProperty.call(modelClass, "_auditCallbacks")) {
372
- modelClass._auditCallbacks = {}
883
+ function createSharedAuditTablesMigration(options = {}) {
884
+ const opts = /** @type {{id?: {type: string}}} */ (options)
885
+ const idOptions = /** @type {{type?: string}} */ (opts.id || {})
886
+ const type = idOptions.type
887
+
888
+ return {
889
+ async up(table) {
890
+ await table.createTable("audit_actions", {id: /** @type {{type?: string}} */ (opts.id || {})}, (/** @type {{string(name: string, options: Record<string, unknown>): void, timestamps(): void}} */ t) => {
891
+ t.string("action", {index: {unique: true}, null: false})
892
+ t.timestamps()
893
+ })
894
+
895
+ await table.createTable("audit_auditable_types", {id: /** @type {{type?: string}} */ (opts.id || {})}, (/** @type {{string(name: string, options: Record<string, unknown>): void, timestamps(): void}} */ t) => {
896
+ t.string("name", {index: {unique: true}, null: false})
897
+ t.timestamps()
898
+ })
899
+
900
+ await table.createTable("audits", {id: /** @type {{type?: string}} */ (opts.id || {})}, (/** @type {{json(name: string): void, references(name: string, options: Record<string, unknown>): void, timestamps(): void}} */ t) => {
901
+ t.references("audit_action", {foreignKey: true, null: false, type})
902
+ t.references("audit_auditable_type", {foreignKey: true, null: false, type})
903
+ t.references("auditable", {null: false, polymorphic: true, type})
904
+ t.json("audited_changes")
905
+ t.json("params")
906
+ t.timestamps()
907
+ })
908
+ },
909
+
910
+ async down(table) {
911
+ await table.dropTable("audits")
912
+ await table.dropTable("audit_auditable_types")
913
+ await table.dropTable("audit_actions")
914
+ }
373
915
  }
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
916
  }
381
917
 
382
918
  /**
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>}
919
+ * Returns the dedicated audit table name for a given model table name.
920
+ * @param {string} modelTableName - Model table name (e.g. "projects").
921
+ * @returns {string} Dedicated audit table name (e.g. "project_audits").
388
922
  */
389
- async function emitAuditEvent(modelClass, action, payload) {
390
- const callbacks = auditCallbacksForModelClass(modelClass)[action] || []
391
-
392
- for (const callback of [...callbacks]) {
393
- await callback(payload)
923
+ function dedicatedAuditTableNameForTable(modelTableName) {
924
+ if (modelTableName.endsWith("s")) {
925
+ return `${modelTableName.slice(0, -1)}_audits`
394
926
  }
927
+
928
+ return `${modelTableName}_audits`
395
929
  }
396
930
 
397
931
  export {
932
+ AuditEvents,
398
933
  captureCreateAuditChanges,
399
934
  captureUpdateAuditChanges,
400
935
  createAudit,
401
936
  createCreateAudit,
402
937
  createDestroyAudit,
403
938
  createUpdateAudit,
939
+ createSharedAuditTablesMigration,
940
+ dedicatedAuditTableNameForTable,
941
+ initializeAuditedModelRelationships,
942
+ initializeAuditing,
404
943
  registerAuditCallback,
405
944
  registerAuditing,
406
945
  withoutAudit