adapt-authoring-core 2.2.4 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/hooks.md CHANGED
@@ -8,12 +8,13 @@ A hook is a point in the code where external observers can run their own functio
8
8
 
9
9
  All hook observers must complete before the operation continues. For example, a document won't be inserted until all `preInsertHook` observers have finished executing.
10
10
 
11
- ### Mutable vs. non-mutable
11
+ ### Hook types
12
12
 
13
- Hooks can be either **mutable** or **immutable**:
13
+ Hooks support three execution types:
14
14
 
15
- - **Immutable**: the _default_ behaviour. Observers are run in parallel (at the same time). When running in series, observers receive a deep copy of arguments to prevent unintended modifications.
16
- - **Mutable**: hooks allow modification of param data, and run observers in series (one after another) to ensure modifications are applied in order.
15
+ - **Parallel** (default): observers run at the same time. Observers receive a deep copy of arguments to prevent unintended modifications.
16
+ - **Series**: observers run one after another. When `mutable: true` is set, observers can modify shared arguments in place.
17
+ - **Middleware**: observers wrap a core function using a `next()` pattern (like Express middleware). Each observer receives `(next, ...args)` and must call `next(...args)` to continue the chain. This allows observers to run logic both before and after the core operation, with shared scope across both.
17
18
 
18
19
  ## Basic usage
19
20
 
@@ -32,12 +33,23 @@ class MyModule extends AbstractModule {
32
33
 
33
34
  // force observers to run in series
34
35
  this.mySeriesHook = new Hook({ type: Hook.Types.Series })
36
+
37
+ // middleware hook — observers wrap a core function
38
+ this.myMiddlewareHook = new Hook({ type: Hook.Types.Middleware })
35
39
  }
36
40
 
37
41
  async doSomething () {
38
42
  // Invoke the hook, passing any relevant data
39
43
  await this.myBasicHook.invoke(someData)
40
44
  }
45
+
46
+ async doSomethingWrapped (data) {
47
+ // Invoke middleware hook — first arg is the core function, rest are passed through
48
+ const coreFn = async (data) => {
49
+ return await this.db.insert(data)
50
+ }
51
+ return this.myMiddlewareHook.invoke(coreFn, data)
52
+ }
41
53
  }
42
54
  ```
43
55
 
@@ -106,21 +118,24 @@ try {
106
118
 
107
119
  Below are some commonly used hooks, which you may find useful.
108
120
 
109
- | Module | Hook | Description | Parameters | Mutable |
110
- | ------ | ---- | ----------- | ---------- | :-----: |
111
- | AbstractModule | `readyHook` | Module has initialised | | No |
112
- | AbstractApiModule | `requestHook` | API request received | `(req)` | Yes |
113
- | AbstractApiModule | `preInsertHook` | Before document insert | `(data, options, mongoOptions)` | Yes |
114
- | AbstractApiModule | `postInsertHook` | After document insert | `(doc)` | No |
115
- | AbstractApiModule | `preUpdateHook` | Before document update | `(originalDoc, newData, options, mongoOptions)` | Yes |
116
- | AbstractApiModule | `postUpdateHook` | After document update | `(originalDoc, updatedDoc)` | No |
117
- | AbstractApiModule | `preDeleteHook` | Before document delete | `(doc, options, mongoOptions)` | No |
118
- | AbstractApiModule | `postDeleteHook` | After document delete | `(doc)` | No |
119
- | AbstractApiModule | `accessCheckHook` | Check document access | `(req, doc)` | No |
120
- | AdaptFrameworkBuild | `preBuildHook` | Before course build starts | | Yes |
121
- | AdaptFrameworkBuild | `postBuildHook` | After course build completes | | Yes |
122
- | AdaptFrameworkModule | `preImportHook` | Before course import starts | | Yes |
123
- | AdaptFrameworkModule | `postImportHook` | After course import completes | | No |
121
+ | Module | Hook | Description | Parameters | Type |
122
+ | ------ | ---- | ----------- | ---------- | :--: |
123
+ | AbstractModule | `readyHook` | Module has initialised | | Parallel |
124
+ | AbstractApiModule | `requestHook` | API request received | `(req)` | Mutable |
125
+ | AbstractApiModule | `insertHook` | Wraps the insert operation | `(next, data, options, mongoOptions)` | Middleware |
126
+ | AbstractApiModule | `updateHook` | Wraps the update operation | `(next, query, data, options, mongoOptions)` | Middleware |
127
+ | AbstractApiModule | `deleteHook` | Wraps the delete operation | `(next, query, options, mongoOptions)` | Middleware |
128
+ | AbstractApiModule | `preInsertHook` | Before document insert | `(data, options, mongoOptions)` | Mutable |
129
+ | AbstractApiModule | `postInsertHook` | After document insert | `(doc)` | Parallel |
130
+ | AbstractApiModule | `preUpdateHook` | Before document update | `(originalDoc, newData, options, mongoOptions)` | Mutable |
131
+ | AbstractApiModule | `postUpdateHook` | After document update | `(originalDoc, updatedDoc)` | Parallel |
132
+ | AbstractApiModule | `preDeleteHook` | Before document delete | `(doc, options, mongoOptions)` | Parallel |
133
+ | AbstractApiModule | `postDeleteHook` | After document delete | `(doc)` | Parallel |
134
+ | AbstractApiModule | `accessCheckHook` | Check document access | `(req, doc)` | Parallel |
135
+ | AdaptFrameworkBuild | `preBuildHook` | Before course build starts | | Mutable |
136
+ | AdaptFrameworkBuild | `postBuildHook` | After course build completes | | Mutable |
137
+ | AdaptFrameworkModule | `preImportHook` | Before course import starts | | Mutable |
138
+ | AdaptFrameworkModule | `postImportHook` | After course import completes | | Parallel |
124
139
 
125
140
  ## Practical examples
126
141
 
@@ -201,6 +216,54 @@ async init () {
201
216
  }
202
217
  ```
203
218
 
219
+ ### Wrapping a CRUD operation (middleware)
220
+
221
+ Middleware hooks let you run logic both before and after the core operation, with shared scope. This is useful when you need pre-operation state to inform post-operation actions.
222
+
223
+ ```javascript
224
+ async init () {
225
+ await super.init()
226
+ const content = await this.app.waitForModule('content')
227
+
228
+ content.deleteHook.tap(async (next, query, options, mongoOptions) => {
229
+ // gather related data BEFORE the delete
230
+ const item = await content.findOne(query)
231
+ const related = await this.findRelated(item)
232
+
233
+ // run the actual delete
234
+ const result = await next(query, options, mongoOptions)
235
+
236
+ // clean up related data AFTER — item and related are still in scope
237
+ for (const r of related) {
238
+ await this.cleanup(r)
239
+ }
240
+
241
+ return result
242
+ })
243
+ }
244
+ ```
245
+
246
+ You can also use middleware to guard operations. If you don't call `next()`, the operation is blocked:
247
+
248
+ ```javascript
249
+ content.insertHook.tap(async (next, data, options, mongoOptions) => {
250
+ if (data._restricted) {
251
+ throw new Error('Cannot insert restricted items')
252
+ }
253
+ return next(data, options, mongoOptions)
254
+ })
255
+ ```
256
+
257
+ ### Choosing between pre/post hooks and middleware
258
+
259
+ Use **pre/post hooks** when you only care about one side of an operation — mutating data before a write, or reacting after one. Use **middleware** when you need to own the full lifecycle: gathering state before, acting after, with shared context across both.
260
+
261
+ Middleware wraps the entire operation including pre/post hooks:
262
+
263
+ ```
264
+ middleware → pre-hook → validate → write → post-hook → middleware returns
265
+ ```
266
+
204
267
  ### Waiting for server startup
205
268
 
206
269
  ```javascript
package/lib/Hook.js CHANGED
@@ -9,11 +9,13 @@ class Hook {
9
9
  * @type {Object}
10
10
  * @property {String} Parallel
11
11
  * @property {String} Series
12
+ * @property {String} Middleware
12
13
  */
13
14
  static get Types () {
14
15
  return {
15
16
  Parallel: 'parallel',
16
- Series: 'series'
17
+ Series: 'series',
18
+ Middleware: 'middleware'
17
19
  }
18
20
  }
19
21
 
@@ -69,7 +71,10 @@ class Hook {
69
71
  async invoke (...args) {
70
72
  let error, data
71
73
  try {
72
- if (this._options.type === Hook.Types.Parallel) {
74
+ if (this._options.type === Hook.Types.Middleware) {
75
+ const [coreFn, ...rest] = args
76
+ data = await this._invokeMiddleware(coreFn, ...rest)
77
+ } else if (this._options.type === Hook.Types.Parallel) {
73
78
  data = await Promise.all(this._hookObservers.map(o => o(...args)))
74
79
  } else {
75
80
  // if not mutable, send a deep copy of the args to avoid any meddling
@@ -82,6 +87,23 @@ class Hook {
82
87
  if (error) throw error
83
88
  return data
84
89
  }
90
+
91
+ /**
92
+ * Builds and invokes a middleware chain around a core function.
93
+ * Each observer receives (next, ...args) and must call next(...args) to continue the chain.
94
+ * @param {Function} coreFn The core function to wrap
95
+ * @param {...*} args Arguments to pass through the chain
96
+ * @return {Promise}
97
+ */
98
+ async _invokeMiddleware (coreFn, ...args) {
99
+ let fn = coreFn
100
+ for (let i = this._hookObservers.length - 1; i >= 0; i--) {
101
+ const observer = this._hookObservers[i]
102
+ const next = fn
103
+ fn = (...a) => observer(next, ...a)
104
+ }
105
+ return fn(...args)
106
+ }
85
107
  }
86
108
 
87
109
  export default Hook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-core",
3
- "version": "2.2.4",
3
+ "version": "2.3.0",
4
4
  "description": "A bundle of reusable 'core' functionality",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-core",
6
6
  "license": "GPL-3.0",
@@ -11,11 +11,13 @@ describe('Hook', () => {
11
11
  it('should expose Series type', () => {
12
12
  assert.equal(Hook.Types.Series, 'series')
13
13
  })
14
- })
15
14
 
16
- describe('.Types', () => {
17
- it('should have exactly two type keys', () => {
18
- assert.equal(Object.keys(Hook.Types).length, 2)
15
+ it('should expose Middleware type', () => {
16
+ assert.equal(Hook.Types.Middleware, 'middleware')
17
+ })
18
+
19
+ it('should have exactly three type keys', () => {
20
+ assert.equal(Object.keys(Hook.Types).length, 3)
19
21
  })
20
22
  })
21
23
 
@@ -342,6 +344,146 @@ describe('Hook', () => {
342
344
  assert.deepEqual(arr, [1, 2, 3])
343
345
  })
344
346
  })
347
+
348
+ describe('middleware hooks', () => {
349
+ it('should call the core function when no observers', async () => {
350
+ const hook = new Hook({ type: Hook.Types.Middleware })
351
+ let called = false
352
+ const core = async () => { called = true; return 'result' }
353
+ const result = await hook.invoke(core)
354
+ assert.equal(called, true)
355
+ assert.equal(result, 'result')
356
+ })
357
+
358
+ it('should pass arguments through to the core function', async () => {
359
+ const hook = new Hook({ type: Hook.Types.Middleware })
360
+ let receivedArgs
361
+ const core = async (...args) => { receivedArgs = args }
362
+ await hook.invoke(core, 'a', 'b', 'c')
363
+ assert.deepEqual(receivedArgs, ['a', 'b', 'c'])
364
+ })
365
+
366
+ it('should wrap the core function with a single observer', async () => {
367
+ const hook = new Hook({ type: Hook.Types.Middleware })
368
+ const order = []
369
+ hook.tap(async (next, arg) => {
370
+ order.push('before')
371
+ const result = await next(arg)
372
+ order.push('after')
373
+ return result
374
+ })
375
+ const core = async (arg) => { order.push('core'); return arg }
376
+ await hook.invoke(core, 'data')
377
+ assert.deepEqual(order, ['before', 'core', 'after'])
378
+ })
379
+
380
+ it('should return the core function result through the chain', async () => {
381
+ const hook = new Hook({ type: Hook.Types.Middleware })
382
+ hook.tap(async (next, val) => next(val))
383
+ const core = async (val) => val * 2
384
+ const result = await hook.invoke(core, 5)
385
+ assert.equal(result, 10)
386
+ })
387
+
388
+ it('should execute multiple observers in order', async () => {
389
+ const hook = new Hook({ type: Hook.Types.Middleware })
390
+ const order = []
391
+ hook.tap(async (next, arg) => {
392
+ order.push('outer-before')
393
+ const result = await next(arg)
394
+ order.push('outer-after')
395
+ return result
396
+ })
397
+ hook.tap(async (next, arg) => {
398
+ order.push('inner-before')
399
+ const result = await next(arg)
400
+ order.push('inner-after')
401
+ return result
402
+ })
403
+ const core = async () => { order.push('core') }
404
+ await hook.invoke(core)
405
+ assert.deepEqual(order, ['outer-before', 'inner-before', 'core', 'inner-after', 'outer-after'])
406
+ })
407
+
408
+ it('should allow observers to modify arguments before core', async () => {
409
+ const hook = new Hook({ type: Hook.Types.Middleware })
410
+ hook.tap(async (next, data) => next({ ...data, extra: true }))
411
+ let received
412
+ const core = async (data) => { received = data; return data }
413
+ await hook.invoke(core, { original: true })
414
+ assert.deepEqual(received, { original: true, extra: true })
415
+ })
416
+
417
+ it('should allow observers to modify the return value after core', async () => {
418
+ const hook = new Hook({ type: Hook.Types.Middleware })
419
+ hook.tap(async (next, val) => {
420
+ const result = await next(val)
421
+ return result + ' modified'
422
+ })
423
+ const core = async (val) => val
424
+ const result = await hook.invoke(core, 'original')
425
+ assert.equal(result, 'original modified')
426
+ })
427
+
428
+ it('should allow an observer to short-circuit without calling next', async () => {
429
+ const hook = new Hook({ type: Hook.Types.Middleware })
430
+ let coreCalled = false
431
+ hook.tap(async () => 'blocked')
432
+ const core = async () => { coreCalled = true; return 'core' }
433
+ const result = await hook.invoke(core)
434
+ assert.equal(coreCalled, false)
435
+ assert.equal(result, 'blocked')
436
+ })
437
+
438
+ it('should propagate errors from the core function', async () => {
439
+ const hook = new Hook({ type: Hook.Types.Middleware })
440
+ hook.tap(async (next) => next())
441
+ const core = async () => { throw new Error('core error') }
442
+ await assert.rejects(hook.invoke(core), { message: 'core error' })
443
+ })
444
+
445
+ it('should propagate errors from observers', async () => {
446
+ const hook = new Hook({ type: Hook.Types.Middleware })
447
+ hook.tap(async () => { throw new Error('observer error') })
448
+ const core = async () => 'ok'
449
+ await assert.rejects(hook.invoke(core), { message: 'observer error' })
450
+ })
451
+
452
+ it('should allow observer to catch and handle core errors', async () => {
453
+ const hook = new Hook({ type: Hook.Types.Middleware })
454
+ hook.tap(async (next) => {
455
+ try {
456
+ return await next()
457
+ } catch {
458
+ return 'recovered'
459
+ }
460
+ })
461
+ const core = async () => { throw new Error('fail') }
462
+ const result = await hook.invoke(core)
463
+ assert.equal(result, 'recovered')
464
+ })
465
+
466
+ it('should support shared state between before and after phases', async () => {
467
+ const hook = new Hook({ type: Hook.Types.Middleware })
468
+ hook.tap(async (next, data) => {
469
+ const snapshot = { ...data }
470
+ const result = await next(data)
471
+ return { result, snapshot }
472
+ })
473
+ const core = async (data) => { data.mutated = true; return data }
474
+ const output = await hook.invoke(core, { value: 1 })
475
+ assert.deepEqual(output.snapshot, { value: 1 })
476
+ assert.equal(output.result.mutated, true)
477
+ })
478
+
479
+ it('should handle sync observers', async () => {
480
+ const hook = new Hook({ type: Hook.Types.Middleware })
481
+ hook.tap((next, val) => next(val))
482
+ const core = (val) => val + 1
483
+ const result = await hook.invoke(core, 1)
484
+ assert.equal(result, 2)
485
+ })
486
+ })
345
487
  })
346
488
 
347
489
  describe('#onInvoke()', () => {