velocious 1.0.507 → 1.0.509

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,145 @@ 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 && shouldResolveAuditTableData(auditedModelClass)) {
389
+ await resolveAuditTableData(auditedModelClass)
390
+ }
391
+ }
79
392
 
80
- if (index >= 0) {
81
- actionCallbacks.splice(index, 1)
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
+ const modelClasses = Object.values(configuration.getModelClasses())
400
+ const shouldResolveTableData = modelClasses.some((modelClass) => shouldResolveAuditTableData(modelClass))
401
+
402
+ if (!shouldResolveTableData) {
403
+ for (const modelClass of modelClasses) {
404
+ await initializeAuditing(modelClass)
82
405
  }
406
+
407
+ return
83
408
  }
409
+
410
+ await configuration.ensureConnections({name: "Initialize audited model relationships"}, async () => {
411
+ for (const modelClass of modelClasses) {
412
+ await initializeAuditing(modelClass, {resolveTableData: true})
413
+ }
414
+ })
84
415
  }
85
416
 
86
417
  /**
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.
418
+ * Checks whether audit table metadata should be resolved for a model class.
419
+ * @param {typeof import("./index.js").default} modelClass - Model class to inspect.
420
+ * @returns {boolean} Whether table metadata resolution should run now.
90
421
  */
91
- function auditChangesForCurrentChanges(record) {
92
- const changes = record.changes()
93
- /** @type {AuditChanges} */
94
- const auditedChanges = {}
95
- const columnNameToAttributeName = record.getModelClass().getColumnNameToAttributeNameMap()
422
+ function shouldResolveAuditTableData(modelClass) {
423
+ const auditedModelClass = /** @type {AuditedModelClass} */ (modelClass)
96
424
 
97
- for (const [attributeName, change] of Object.entries(changes)) {
98
- auditedChanges[columnNameToAttributeName[attributeName] || attributeName] = change[1]
425
+ if (!auditedModelClass._auditLifecycleCallbacksRegistered) {
426
+ return false
99
427
  }
100
428
 
101
- return auditedChanges
429
+ return Boolean(auditedModelClass._initialized) && canResolveAuditTableData(auditedModelClass)
102
430
  }
103
431
 
104
432
  /**
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.
433
+ * Registers the audits relationship without forcing audit table detection.
434
+ * @param {AuditedModelClass} modelClass - Model class to audit.
435
+ * @param {AuditTableData} [tableData] - Resolved audit table data when available.
436
+ * @returns {void}
108
437
  */
109
- function auditChangesForDestroy(record) {
110
- return {...record.attributes()}
438
+ function registerAuditRelationship(modelClass, tableData = defaultAuditRelationshipTableData(modelClass)) {
439
+ if (modelClass._relationshipExists("audits")) {
440
+ const relationship = modelClass.getRelationshipByName("audits")
441
+
442
+ if (generatedAuditRelationships.has(relationship)) {
443
+ relationship.className = undefined
444
+ relationship.klass = tableData.auditClass
445
+ relationship.foreignKey = tableData.foreignKey
446
+ relationship._explicitForeignKey = tableData.foreignKey
447
+ relationship._polymorphic = !tableData.dedicated
448
+ relationship._polymorphicTypeColumn = undefined
449
+ }
450
+
451
+ return
452
+ }
453
+
454
+ modelClass.hasMany("audits", auditRelationshipScope, {
455
+ foreignKey: tableData.foreignKey,
456
+ klass: tableData.auditClass,
457
+ polymorphic: !tableData.dedicated
458
+ })
459
+ generatedAuditRelationships.add(modelClass.getRelationshipByName("audits"))
111
460
  }
112
461
 
462
+ /**
463
+ * Returns unresolved shared-audit defaults for early relationship registration.
464
+ * @param {AuditedModelClass} modelClass - Model class to audit.
465
+ * @returns {AuditTableData} Shared audit relationship defaults.
466
+ */
467
+ function defaultAuditRelationshipTableData(modelClass) {
468
+ return {
469
+ auditClass: cachedSharedAuditClass(modelClass),
470
+ dedicated: false,
471
+ foreignKey: "auditable_id",
472
+ tableName: "audits"
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Applies default audit ordering to generated audit relationships.
478
+ * @param {import("../query/model-class-query.js").default<typeof import("./index.js").default>} query - Audit query.
479
+ * @returns {import("../query/model-class-query.js").default<typeof import("./index.js").default>} Ordered audit query.
480
+ */
481
+ function auditRelationshipScope(query) {
482
+ return query.order({column: "created_at", direction: "DESC"})
483
+ }
484
+
485
+ /**
486
+ * Checks whether the current tenant context can resolve audit table data.
487
+ * @param {AuditedModelClass} modelClass - Model class to inspect.
488
+ * @returns {boolean} Whether audit table data can be resolved in the current scope.
489
+ */
490
+ function canResolveAuditTableData(modelClass) {
491
+ if (!modelClass.hasTenantDatabaseIdentifierResolver()) {
492
+ return true
493
+ }
494
+
495
+ const tenantDatabaseIdentifier = modelClass.getTenantDatabaseIdentifier()
496
+
497
+ if (!tenantDatabaseIdentifier) {
498
+ return false
499
+ }
500
+
501
+ return modelClass._getConfiguration().isDatabaseIdentifierActive(tenantDatabaseIdentifier)
502
+ }
503
+
504
+ // ---------------------------------------------------------------------------
505
+ // Creating audits
506
+ // ---------------------------------------------------------------------------
507
+
113
508
  /**
114
509
  * Creates an audit row for a record.
115
510
  * @param {import("./index.js").default} record - Record to audit.
116
- * @param {CreateAuditArgs} args - Audit row options.
511
+ * @param {CreateAuditArgs} args - Audit row options (action, auditedChanges, params).
117
512
  * @returns {Promise<number | string>} Created audit row id.
118
513
  */
119
514
  async function createAudit(record, args) {
@@ -126,85 +521,100 @@ async function createAudit(record, args) {
126
521
 
127
522
  /**
128
523
  * Creates an audit row using the current model database connection.
524
+ * Routes to shared table or dedicated table based on resolved audit table data.
129
525
  * @param {import("./index.js").default} record - Record to audit.
130
- * @param {CreateAuditArgs} args - Audit row options.
526
+ * @param {CreateAuditArgs} args - Audit row options (action, auditedChanges, params).
131
527
  * @param {AuditedModelClass} modelClass - Audited model class.
132
528
  * @returns {Promise<number | string>} Created audit row id.
133
529
  */
134
530
  async function createAuditWithCurrentConnection(record, args, modelClass) {
135
531
  if (!record.isPersisted()) throw new Error(`Cannot audit unpersisted ${modelClass.getModelName()} record`)
136
532
 
533
+ const tableData = await resolveAuditTableData(modelClass)
137
534
  const action = normalizeAction(args.action)
138
535
  const auditedChanges = args.auditedChanges === undefined ? null : args.auditedChanges
139
536
  const params = args.params === undefined ? null : args.params
140
537
  const db = modelClass.connection()
141
538
  const currentDate = new Date()
539
+
142
540
  const auditActionId = await findOrCreateLookupId({
143
541
  columnName: "action",
542
+ currentDate,
144
543
  db,
145
544
  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
545
+ value: action
155
546
  })
547
+
156
548
  const auditId = new UUID(4).format()
157
549
 
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
550
+ if (tableData.dedicated) {
551
+ const modelKey = modelParamKey(modelClass)
552
+
553
+ await db.query(db.insertSql({
554
+ returnLastInsertedColumnNames: ["id"],
555
+ tableName: tableData.tableName,
556
+ data: {
557
+ id: auditId,
558
+ [`${modelKey}_id`]: record.id(),
559
+ audit_action_id: auditActionId,
560
+ audited_changes: auditedChanges,
561
+ params,
562
+ created_at: currentDate,
563
+ updated_at: currentDate
564
+ }
565
+ }))
566
+ } else {
567
+ const auditAuditableTypeId = await findOrCreateLookupId({
568
+ columnName: "name",
569
+ currentDate,
570
+ db,
571
+ tableName: "audit_auditable_types",
572
+ value: modelClass.getModelName()
573
+ })
574
+
575
+ await db.query(db.insertSql({
576
+ returnLastInsertedColumnNames: ["id"],
577
+ tableName: "audits",
578
+ data: {
579
+ id: auditId,
580
+ audit_action_id: auditActionId,
581
+ audit_auditable_type_id: auditAuditableTypeId,
582
+ auditable_id: record.id(),
583
+ auditable_type: modelClass.getModelName(),
584
+ audited_changes: auditedChanges,
585
+ params,
586
+ created_at: currentDate,
587
+ updated_at: currentDate
588
+ }
589
+ }))
590
+ }
174
591
 
175
592
  await emitAuditEvent(modelClass, action, {
176
593
  action,
177
- auditId: insertedAuditId,
594
+ auditId,
178
595
  auditedChanges,
179
596
  params,
180
597
  record
181
598
  })
182
599
 
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
600
+ AuditEvents.call(modelClass.getModelName(), action, {
601
+ action,
602
+ auditId,
603
+ auditedChanges,
604
+ params,
605
+ record
606
+ })
201
607
 
202
- return null
608
+ return auditId
203
609
  }
204
610
 
611
+ // ---------------------------------------------------------------------------
612
+ // Lifecycle callbacks
613
+ // ---------------------------------------------------------------------------
614
+
205
615
  /**
206
- * Captures create changes on a model instance.
207
- * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to capture.
616
+ * Captures create changes before persistence clears the change set.
617
+ * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record whose pending changes should be captured.
208
618
  * @returns {void}
209
619
  */
210
620
  function captureCreateAuditChanges(record) {
@@ -212,7 +622,7 @@ function captureCreateAuditChanges(record) {
212
622
  }
213
623
 
214
624
  /**
215
- * Creates the create audit row for a model instance.
625
+ * Writes the create audit row for a model instance.
216
626
  * @param {import("./index.js").default & {_pendingCreateAuditChanges?: AuditChanges}} record - Record to audit.
217
627
  * @returns {Promise<void>}
218
628
  */
@@ -226,8 +636,8 @@ async function createCreateAudit(record) {
226
636
  }
227
637
 
228
638
  /**
229
- * Captures update changes on a model instance.
230
- * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to capture.
639
+ * Captures update changes before persistence clears the change set.
640
+ * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record whose pending changes should be captured.
231
641
  * @returns {void}
232
642
  */
233
643
  function captureUpdateAuditChanges(record) {
@@ -235,7 +645,7 @@ function captureUpdateAuditChanges(record) {
235
645
  }
236
646
 
237
647
  /**
238
- * Creates the update audit row for a model instance.
648
+ * Writes the update audit row for a model instance.
239
649
  * @param {import("./index.js").default & {_pendingUpdateAuditChanges?: AuditChanges}} record - Record to audit.
240
650
  * @returns {Promise<void>}
241
651
  */
@@ -253,7 +663,7 @@ async function createUpdateAudit(record) {
253
663
  }
254
664
 
255
665
  /**
256
- * Creates the destroy audit row for a model instance.
666
+ * Writes the destroy audit row for a model instance.
257
667
  * @param {import("./index.js").default} record - Record to audit.
258
668
  * @returns {Promise<void>}
259
669
  */
@@ -264,8 +674,45 @@ async function createDestroyAudit(record) {
264
674
  })
265
675
  }
266
676
 
677
+ // ---------------------------------------------------------------------------
678
+ // Changes helpers
679
+ // ---------------------------------------------------------------------------
680
+
681
+ /**
682
+ * Captures the new values for fields changed on a record.
683
+ * @param {import("./index.js").default} record - Record whose pending changes should be captured.
684
+ * @returns {AuditChanges} New values keyed by attribute name.
685
+ */
686
+ function auditChangesForCurrentChanges(record) {
687
+ const changes = record.changes()
688
+ /** @type {AuditChanges} */
689
+ const auditedChanges = {}
690
+ const columnNameToAttributeName = record.getModelClass().getColumnNameToAttributeNameMap()
691
+
692
+ for (const [attributeName, change] of Object.entries(changes)) {
693
+ auditedChanges[columnNameToAttributeName[attributeName] || attributeName] = change[1]
694
+ }
695
+
696
+ return auditedChanges
697
+ }
698
+
699
+ /**
700
+ * Captures the current attributes for a destroy audit.
701
+ * @param {import("./index.js").default} record - Record being destroyed.
702
+ * @returns {AuditChanges} Current attributes keyed by attribute name.
703
+ */
704
+ function auditChangesForDestroy(record) {
705
+ return {...record.attributes()}
706
+ }
707
+
708
+ // ---------------------------------------------------------------------------
709
+ // withoutAudit
710
+ // ---------------------------------------------------------------------------
711
+
267
712
  /**
268
713
  * Returns records without an audit row for the given action.
714
+ * Uses shared-table defaults when table data is not yet resolved;
715
+ * switches to the dedicated table path once resolved.
269
716
  * @template {AuditedModelClass} MC
270
717
  * @param {MC} modelClass - Model class to scope.
271
718
  * @param {string} action - Audit action to exclude.
@@ -274,14 +721,36 @@ async function createDestroyAudit(record) {
274
721
  function withoutAudit(modelClass, action) {
275
722
  const db = modelClass.connection()
276
723
  const modelTableSql = db.quoteTable(modelClass.tableName())
277
- const auditsTableSql = db.quoteTable("audits")
278
724
  const auditActionsTableSql = db.quoteTable("audit_actions")
279
725
  const modelPrimaryKeySql = `${modelTableSql}.${db.quoteColumn(modelClass.primaryKey())}`
726
+ const auditActionsIdSql = `${auditActionsTableSql}.${db.quoteColumn("id")}`
727
+ const auditActionsActionSql = `${auditActionsTableSql}.${db.quoteColumn("action")}`
728
+
729
+ if (modelClass._auditTableResolved && modelClass._auditTableData?.dedicated) {
730
+ const tableData = modelClass._auditTableData
731
+ const modelKey = modelParamKey(modelClass)
732
+ const auditsTableSql = db.quoteTable(tableData.tableName)
733
+ const auditActionIdSql = `${auditsTableSql}.${db.quoteColumn("audit_action_id")}`
734
+ const modelIdSql = `${auditsTableSql}.${db.quoteColumn(`${modelKey}_id`)}`
735
+
736
+ return modelClass
737
+ .all()
738
+ .where(`
739
+ NOT EXISTS (
740
+ SELECT 1
741
+ FROM ${auditsTableSql}
742
+ INNER JOIN ${auditActionsTableSql}
743
+ ON ${auditActionsIdSql} = ${auditActionIdSql}
744
+ WHERE ${modelIdSql} = ${modelPrimaryKeySql}
745
+ AND ${auditActionsActionSql} = ${db.quote(normalizeAction(action))}
746
+ )
747
+ `)
748
+ }
749
+
750
+ const auditsTableSql = db.quoteTable("audits")
280
751
  const auditAuditableIdSql = `${auditsTableSql}.${db.quoteColumn("auditable_id")}`
281
752
  const auditAuditableTypeSql = `${auditsTableSql}.${db.quoteColumn("auditable_type")}`
282
753
  const auditActionIdSql = `${auditsTableSql}.${db.quoteColumn("audit_action_id")}`
283
- const auditActionsIdSql = `${auditActionsTableSql}.${db.quoteColumn("id")}`
284
- const auditActionsActionSql = `${auditActionsTableSql}.${db.quoteColumn("action")}`
285
754
 
286
755
  return modelClass
287
756
  .all()
@@ -298,13 +767,80 @@ function withoutAudit(modelClass, action) {
298
767
  `)
299
768
  }
300
769
 
770
+ // ---------------------------------------------------------------------------
771
+ // Event callbacks
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /**
775
+ * Registers a per-model callback fired after an audit row is created.
776
+ * @param {AuditedModelClass} modelClass - Model class to observe.
777
+ * @param {string} action - Audit action name (e.g. "create").
778
+ * @param {AuditCallback} callback - Callback invoked after matching audit rows are created.
779
+ * @returns {() => void} Unsubscribe function.
780
+ */
781
+ function registerAuditCallback(modelClass, action, callback) {
782
+ const normalizedAction = normalizeAction(action)
783
+ const callbacks = auditCallbacksForModelClass(modelClass)
784
+
785
+ if (!callbacks[normalizedAction]) {
786
+ callbacks[normalizedAction] = []
787
+ }
788
+
789
+ callbacks[normalizedAction].push(callback)
790
+
791
+ return () => {
792
+ const actionCallbacks = callbacks[normalizedAction]
793
+ const index = actionCallbacks.indexOf(callback)
794
+
795
+ if (index >= 0) {
796
+ actionCallbacks.splice(index, 1)
797
+ }
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Emits per-model audit callbacks for a model/action pair.
803
+ * @param {AuditedModelClass} modelClass - Model class.
804
+ * @param {string} action - Audit action.
805
+ * @param {AuditEventPayload} payload - Event payload.
806
+ * @returns {Promise<void>}
807
+ */
808
+ async function emitAuditEvent(modelClass, action, payload) {
809
+ const callbacks = auditCallbacksForModelClass(modelClass)[action] || []
810
+
811
+ for (const callback of [...callbacks]) {
812
+ await callback(payload)
813
+ }
814
+ }
815
+
816
+ /**
817
+ * Returns the per-model callback map.
818
+ * @param {AuditedModelClass} modelClass - Model class.
819
+ * @returns {Record<string, AuditCallback[]>} Callback map keyed by action.
820
+ */
821
+ function auditCallbacksForModelClass(modelClass) {
822
+ if (!Object.prototype.hasOwnProperty.call(modelClass, "_auditCallbacks")) {
823
+ modelClass._auditCallbacks = {}
824
+ }
825
+
826
+ const callbacks = modelClass._auditCallbacks
827
+
828
+ if (!callbacks) throw new Error(`Audit callbacks weren't initialized for ${modelClass.getModelName()}`)
829
+
830
+ return callbacks
831
+ }
832
+
833
+ // ---------------------------------------------------------------------------
834
+ // Lookup helpers
835
+ // ---------------------------------------------------------------------------
836
+
301
837
  /**
302
838
  * Finds or creates a lookup row and returns its id.
303
839
  * @param {object} args - Options object.
304
- * @param {string} args.columnName - Lookup value column.
840
+ * @param {string} args.columnName - Lookup value column name.
305
841
  * @param {Date} args.currentDate - Timestamp to write when inserting.
306
842
  * @param {import("../drivers/base.js").default} args.db - Database driver.
307
- * @param {string} args.tableName - Lookup table.
843
+ * @param {string} args.tableName - Lookup table name.
308
844
  * @param {string} args.value - Lookup value.
309
845
  * @returns {Promise<number | string>} Lookup row id.
310
846
  */
@@ -331,9 +867,9 @@ async function findOrCreateLookupId({columnName, currentDate, db, tableName, val
331
867
  /**
332
868
  * Finds a lookup id by value.
333
869
  * @param {object} args - Options object.
334
- * @param {string} args.columnName - Lookup value column.
870
+ * @param {string} args.columnName - Lookup value column name.
335
871
  * @param {import("../drivers/base.js").default} args.db - Database driver.
336
- * @param {string} args.tableName - Lookup table.
872
+ * @param {string} args.tableName - Lookup table name.
337
873
  * @param {string} args.value - Lookup value.
338
874
  * @returns {Promise<number | string | null>} Lookup row id or null.
339
875
  */
@@ -350,9 +886,9 @@ async function findLookupId({columnName, db, tableName, value}) {
350
886
  }
351
887
 
352
888
  /**
353
- * Normalizes an audit action.
889
+ * Normalizes an audit action string.
354
890
  * @param {string} action - Action name.
355
- * @returns {string} Normalized action.
891
+ * @returns {string} Trimmed, non-empty action name.
356
892
  */
357
893
  function normalizeAction(action) {
358
894
  const normalizedAction = action.trim()
@@ -362,45 +898,76 @@ function normalizeAction(action) {
362
898
  return normalizedAction
363
899
  }
364
900
 
901
+ // ---------------------------------------------------------------------------
902
+ // Migration helpers
903
+ // ---------------------------------------------------------------------------
904
+
365
905
  /**
366
- * Returns the callback map owned by a model class.
367
- * @param {AuditedModelClass} modelClass - Model class.
368
- * @returns {Record<string, AuditCallback[]>} Callback map.
906
+ * Creates the shared audit tables migration up/down callbacks for use inside
907
+ * a Migration class. The `table` parameter is a Migration instance.
908
+ * @param {{id?: {type: string}}} [options] - ID column options.
909
+ * @returns {{down: (table: import("../migration/index.js").default) => Promise<void>, up: (table: import("../migration/index.js").default) => Promise<void>}}
369
910
  */
370
- function auditCallbacksForModelClass(modelClass) {
371
- if (!Object.prototype.hasOwnProperty.call(modelClass, "_auditCallbacks")) {
372
- modelClass._auditCallbacks = {}
911
+ function createSharedAuditTablesMigration(options = {}) {
912
+ const opts = /** @type {{id?: {type: string}}} */ (options)
913
+ const idOptions = /** @type {{type?: string}} */ (opts.id || {})
914
+ const type = idOptions.type
915
+
916
+ return {
917
+ async up(table) {
918
+ await table.createTable("audit_actions", {id: /** @type {{type?: string}} */ (opts.id || {})}, (/** @type {{string(name: string, options: Record<string, unknown>): void, timestamps(): void}} */ t) => {
919
+ t.string("action", {index: {unique: true}, null: false})
920
+ t.timestamps()
921
+ })
922
+
923
+ await table.createTable("audit_auditable_types", {id: /** @type {{type?: string}} */ (opts.id || {})}, (/** @type {{string(name: string, options: Record<string, unknown>): void, timestamps(): void}} */ t) => {
924
+ t.string("name", {index: {unique: true}, null: false})
925
+ t.timestamps()
926
+ })
927
+
928
+ 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) => {
929
+ t.references("audit_action", {foreignKey: true, null: false, type})
930
+ t.references("audit_auditable_type", {foreignKey: true, null: false, type})
931
+ t.references("auditable", {null: false, polymorphic: true, type})
932
+ t.json("audited_changes")
933
+ t.json("params")
934
+ t.timestamps()
935
+ })
936
+ },
937
+
938
+ async down(table) {
939
+ await table.dropTable("audits")
940
+ await table.dropTable("audit_auditable_types")
941
+ await table.dropTable("audit_actions")
942
+ }
373
943
  }
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
944
  }
381
945
 
382
946
  /**
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>}
947
+ * Returns the dedicated audit table name for a given model table name.
948
+ * @param {string} modelTableName - Model table name (e.g. "projects").
949
+ * @returns {string} Dedicated audit table name (e.g. "project_audits").
388
950
  */
389
- async function emitAuditEvent(modelClass, action, payload) {
390
- const callbacks = auditCallbacksForModelClass(modelClass)[action] || []
391
-
392
- for (const callback of [...callbacks]) {
393
- await callback(payload)
951
+ function dedicatedAuditTableNameForTable(modelTableName) {
952
+ if (modelTableName.endsWith("s")) {
953
+ return `${modelTableName.slice(0, -1)}_audits`
394
954
  }
955
+
956
+ return `${modelTableName}_audits`
395
957
  }
396
958
 
397
959
  export {
960
+ AuditEvents,
398
961
  captureCreateAuditChanges,
399
962
  captureUpdateAuditChanges,
400
963
  createAudit,
401
964
  createCreateAudit,
402
965
  createDestroyAudit,
403
966
  createUpdateAudit,
967
+ createSharedAuditTablesMigration,
968
+ dedicatedAuditTableNameForTable,
969
+ initializeAuditedModelRelationships,
970
+ initializeAuditing,
404
971
  registerAuditCallback,
405
972
  registerAuditing,
406
973
  withoutAudit