adapt-authoring-core 2.5.0 → 3.0.1

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/lib/Config.js ADDED
@@ -0,0 +1,226 @@
1
+ import { Schemas } from 'adapt-schemas'
2
+ import fs from 'fs/promises'
3
+ import path from 'path'
4
+
5
+ /**
6
+ * Loads, validates, and provides access to application configuration.
7
+ * Configuration is sourced from user settings files, environment variables, and module schema defaults.
8
+ * @memberof core
9
+ */
10
+ class Config {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {String} options.rootDir Application root directory
14
+ * @param {String} options.configFilePath Path to the user configuration file
15
+ * @param {Object} options.dependencies Key/value map of dependency configs
16
+ * @param {String} options.appName The core module name (for sorting)
17
+ * @param {Function} options.log Logging function (level, id, ...args)
18
+ */
19
+ constructor ({ rootDir, configFilePath, dependencies = {}, appName = '', log = () => {} } = {}) {
20
+ /** @ignore */
21
+ this._config = {}
22
+ /**
23
+ * Application root directory
24
+ * @type {String}
25
+ */
26
+ this.rootDir = rootDir
27
+ /**
28
+ * Path to the user configuration file
29
+ * @type {String}
30
+ */
31
+ this.configFilePath = configFilePath
32
+ /**
33
+ * The keys for all attributes marked as public
34
+ * @type {Array<String>}
35
+ */
36
+ this.publicAttributes = []
37
+ /** @ignore */
38
+ this._dependencies = dependencies
39
+ /** @ignore */
40
+ this._appName = appName
41
+ /** @ignore */
42
+ this.log = log
43
+ }
44
+
45
+ /**
46
+ * Loads configuration from all sources
47
+ * @returns {Promise}
48
+ */
49
+ async load () {
50
+ await this.storeUserSettings()
51
+ this.storeEnvSettings()
52
+ this.storeSchemaSettings(this._dependencies, this._appName)
53
+ this.log('info', 'config', `using config at ${this.configFilePath}`)
54
+ return this
55
+ }
56
+
57
+ /**
58
+ * Determines whether an attribute has a set value
59
+ * @param {String} attr Attribute key name
60
+ * @return {Boolean}
61
+ */
62
+ has (attr) {
63
+ return Object.hasOwn(this._config, attr)
64
+ }
65
+
66
+ /**
67
+ * Returns a value for a given attribute
68
+ * @param {String} attr Attribute key name
69
+ * @return {*}
70
+ */
71
+ get (attr) {
72
+ return this._config[attr]
73
+ }
74
+
75
+ /**
76
+ * Retrieves all config options marked as 'public'
77
+ * @return {Object}
78
+ */
79
+ getPublicConfig () {
80
+ return this.publicAttributes.reduce((m, a) => {
81
+ m[a] = this.get(a)
82
+ return m
83
+ }, {})
84
+ }
85
+
86
+ /**
87
+ * Loads the relevant config file into memory
88
+ * @return {Promise}
89
+ */
90
+ async storeUserSettings () {
91
+ let config
92
+ try {
93
+ await fs.readFile(this.configFilePath)
94
+ config = (await import(this.configFilePath)).default
95
+ } catch (e) {
96
+ this.log('warn', 'config', `Failed to load config at ${this.configFilePath}: ${e}. Will attempt to run with defaults.`)
97
+ return
98
+ }
99
+ Object.entries(config).forEach(([name, c]) => {
100
+ Object.entries(c).forEach(([key, val]) => {
101
+ this._config[`${name}.${key}`] = val
102
+ })
103
+ })
104
+ }
105
+
106
+ /**
107
+ * Copy env values to config
108
+ */
109
+ storeEnvSettings () {
110
+ Object.entries(process.env).forEach(([key, val]) => {
111
+ try {
112
+ val = JSON.parse(val)
113
+ } catch {} // ignore parse errors for non-JSON values
114
+ this._config[Config.envVarToConfigKey(key)] = val
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Processes all module config schema files
120
+ * @param {Object} dependencies Key/value map of dependency configs
121
+ * @param {String} appName The core module name (for sorting)
122
+ */
123
+ storeSchemaSettings (dependencies, appName) {
124
+ const schemas = new Schemas().init()
125
+ const isCore = d => d.name === appName
126
+ const deps = Object.values(dependencies).sort((a, b) => {
127
+ if (isCore(a)) return -1
128
+ if (isCore(b)) return 1
129
+ return a.name.localeCompare(b.name)
130
+ })
131
+ const coreDep = deps.find(d => isCore(d))
132
+ if (coreDep) this.processModuleSchema(coreDep, schemas)
133
+
134
+ const errors = []
135
+ for (const d of deps.filter(d => !isCore(d))) {
136
+ try {
137
+ this.processModuleSchema(d, schemas)
138
+ } catch (e) {
139
+ errors.push(e?.data?.errors ? { modName: e.modName, message: e.data.errors } : { message: String(e) })
140
+ }
141
+ }
142
+ if (errors.length) {
143
+ errors.forEach(e => {
144
+ this.log('error', 'config', `${e.modName ? e.modName + ': ' : ''}${e.message}`)
145
+ })
146
+ throw new Error('Config validation failed')
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Processes and validates a single module config schema
152
+ * @param {Object} pkg Package.json data
153
+ * @param {Schemas} schemas Schemas library instance
154
+ */
155
+ processModuleSchema (pkg, schemas) {
156
+ if (!pkg.name || !pkg.rootDir) return
157
+ const schemaPath = path.resolve(pkg.rootDir, 'conf/config.schema.json')
158
+ let schema
159
+ try {
160
+ // TODO config schemas should define $id, remove this workaround once they do
161
+ schema = schemas.createSchema(schemaPath)
162
+ schema.raw.$id = pkg.name
163
+ schema.build({ compile: false })
164
+ schema.built.$id = pkg.name
165
+ schema.compiledWithDefaults = schemas.validatorWithDefaults.compile(schema.built)
166
+ schema.compiled = schemas.validator.compile(schema.built)
167
+ } catch (e) {
168
+ if (e.code !== 'SCHEMA_LOAD_FAILED') {
169
+ this.log('warn', 'config', `${pkg.name}: ${e.message}`)
170
+ }
171
+ return
172
+ }
173
+ const dirKeys = new Set()
174
+ let data = Object.entries(schema.raw.properties).reduce((m, [k, v]) => {
175
+ if (v?._adapt?.isPublic) this.publicAttributes.push(`${pkg.name}.${k}`)
176
+ if (v?.isDirectory) dirKeys.add(k)
177
+ return { ...m, [k]: this.get(`${pkg.name}.${k}`) }
178
+ }, {})
179
+ try {
180
+ data = schema.validate(data)
181
+ } catch (e) {
182
+ e.modName = pkg.name
183
+ throw e
184
+ }
185
+ Object.entries(data).forEach(([key, val]) => {
186
+ if (dirKeys.has(key) && typeof val === 'string') {
187
+ val = this.resolveDirectory(val)
188
+ }
189
+ this._config[`${pkg.name}.${key}`] = val
190
+ })
191
+ }
192
+
193
+ /**
194
+ * Resolves directory path variables ($ROOT, $DATA, $TEMP)
195
+ * @param {String} value The path string to resolve
196
+ * @return {String}
197
+ */
198
+ resolveDirectory (value) {
199
+ const vars = [
200
+ ['$ROOT', this.rootDir],
201
+ ['$DATA', this._config['adapt-authoring-core.dataDir']],
202
+ ['$TEMP', this._config['adapt-authoring-core.tempDir']]
203
+ ]
204
+ for (const [key, replacement] of vars) {
205
+ if (value.startsWith(key) && replacement && !replacement.startsWith('$')) {
206
+ return path.resolve(replacement, value.replace(key, '').slice(1))
207
+ }
208
+ }
209
+ return value
210
+ }
211
+
212
+ /**
213
+ * Parses an environment variable key into a format expected by Config
214
+ * @param {String} envVar
215
+ * @return {String}
216
+ */
217
+ static envVarToConfigKey (envVar) {
218
+ if (envVar.startsWith('ADAPT_AUTHORING_')) {
219
+ const [modPrefix, key] = envVar.split('__')
220
+ return `${modPrefix.replace(/_/g, '-').toLowerCase()}.${key}`
221
+ }
222
+ return `env.${envVar}`
223
+ }
224
+ }
225
+
226
+ export default Config
package/lib/DataCache.js CHANGED
@@ -13,6 +13,8 @@ class DataCache {
13
13
  this.isEnabled = enable === true
14
14
  this.lifespan = lifespan
15
15
  this.cache = {}
16
+ this.hits = 0
17
+ this.misses = 0
16
18
  }
17
19
 
18
20
  /**
@@ -26,8 +28,12 @@ class DataCache {
26
28
  const key = JSON.stringify(query) + JSON.stringify(options) + JSON.stringify(mongoOptions)
27
29
  this.prune()
28
30
  if (this.isEnabled && this.cache[key]) {
31
+ this.hits++
32
+ App.instance.logger?.log('verbose', 'datacache', 'hit', options.collectionName, query)
29
33
  return this.cache[key].data
30
34
  }
35
+ this.misses++
36
+ App.instance.logger?.log('verbose', 'datacache', 'miss', options.collectionName, query)
31
37
  const mongodb = await App.instance.waitForModule('mongodb')
32
38
  const data = await mongodb.find(options.collectionName, query, mongoOptions)
33
39
  this.cache[key] = { data, timestamp: Date.now() }
@@ -1,10 +1,8 @@
1
- /* eslint no-console: 0 */
2
- import _ from 'lodash'
3
- import fs from 'fs-extra'
4
1
  import { glob } from 'glob'
5
2
  import path from 'path'
6
3
  import Hook from './Hook.js'
7
- import { metadataFileName, packageFileName, stripScope } from './Utils.js'
4
+ import { metadataFileName, packageFileName, stripScope, readJson } from './Utils.js'
5
+
8
6
  /**
9
7
  * Handles the loading of Adapt authoring tool module dependencies.
10
8
  * @memberof core
@@ -59,47 +57,21 @@ class DependencyLoader {
59
57
  this.moduleLoadedHook.tap(this.logProgress, this)
60
58
  }
61
59
 
62
- /**
63
- * Loads all Adapt module dependencies. Essential modules are loaded first, then non-essential modules (with force mode).
64
- * @return {Promise<void>}
65
- * @throws {Error} When any essential module fails to load
66
- */
67
- async load () {
68
- await this.loadConfigs()
69
-
70
- const configValues = Object.values(this.configs)
71
- // sort dependencies into priority
72
- const { essential, theRest } = configValues.reduce((m, c) => {
73
- this.app.pkg.essentialApis.includes(c.essentialType) ? m.essential.push(c.name) : m.theRest.push(c.name)
74
- return m
75
- }, { essential: [], theRest: [] })
76
- // load each set of deps
77
- await this.loadModules(essential)
78
- await this.loadModules(theRest, { force: true })
79
-
80
- if (this.failedModules.length) {
81
- throw new Error(`Failed to load modules ${this.failedModules.join(', ')}`)
82
- }
83
- }
84
-
85
60
  /**
86
61
  * Loads configuration files for all Adapt dependencies found in node_modules.
87
62
  * @return {Promise<void>}
88
63
  */
89
64
  async loadConfigs () {
90
65
  /** @ignore */ this._configsLoaded = false
66
+ const corePathSegment = `/${this.app.name}/`
91
67
  const files = await glob(`${this.app.rootDir}/node_modules/**/${metadataFileName}`)
92
68
  const deps = files
93
69
  .map(d => d.replace(`${metadataFileName}`, ''))
94
- .sort((a, b) => a.length < b.length ? -1 : 1)
95
-
96
- // sort so that core is loaded first, as other modules may use its config values
97
- const corePathSegment = `/${this.app.name}/`
98
- deps.sort((a, b) => {
99
- if (a.endsWith(corePathSegment)) return -1
100
- if (b.endsWith(corePathSegment)) return 1
101
- return 0
102
- })
70
+ .sort((a, b) => {
71
+ if (a.endsWith(corePathSegment)) return -1
72
+ if (b.endsWith(corePathSegment)) return 1
73
+ return a.length - b.length
74
+ })
103
75
  for (const d of deps) {
104
76
  try {
105
77
  const c = await this.loadModuleConfig(d)
@@ -112,8 +84,8 @@ class DependencyLoader {
112
84
  }
113
85
  }
114
86
  } catch (e) {
115
- this.logError(`Failed to load config for '${d}', module will not be loaded`)
116
- this.logError(e)
87
+ this.log('error', `Failed to load config for '${d}', module will not be loaded`)
88
+ this.log('error', e)
117
89
  }
118
90
  }
119
91
  this._configsLoaded = true
@@ -126,10 +98,10 @@ class DependencyLoader {
126
98
  * @return {Promise<Object>} Resolves with configuration object
127
99
  */
128
100
  async loadModuleConfig (modDir) {
129
- const pkg = await fs.readJson(path.join(modDir, packageFileName))
101
+ const pkg = await readJson(path.join(modDir, packageFileName))
130
102
  return {
131
103
  ...pkg,
132
- ...await fs.readJson(path.join(modDir, metadataFileName)),
104
+ ...await readJson(path.join(modDir, metadataFileName)),
133
105
  name: stripScope(pkg.name),
134
106
  packageName: pkg.name,
135
107
  rootDir: modDir
@@ -144,7 +116,7 @@ class DependencyLoader {
144
116
  */
145
117
  async loadModule (modName) {
146
118
  if (this.instances[modName]) {
147
- throw new Error('Module already exists')
119
+ throw this.app.errors.DEP_ALREADY_LOADED.setData({ module: modName })
148
120
  }
149
121
  const config = this.configs[modName]
150
122
 
@@ -153,54 +125,48 @@ class DependencyLoader {
153
125
  }
154
126
  const { default: ModClass } = await import(config.packageName)
155
127
 
156
- if (!_.isFunction(ModClass)) {
157
- throw new Error('Expected class to be exported')
128
+ if (typeof ModClass !== 'function') {
129
+ throw this.app.errors.DEP_INVALID_EXPORT.setData({ module: modName })
158
130
  }
159
131
  const instance = new ModClass(this.app, config)
160
132
 
161
- if (!_.isFunction(instance.onReady)) {
162
- throw new Error('Module must define onReady function')
133
+ if (typeof instance.onReady !== 'function') {
134
+ throw this.app.errors.DEP_NO_ONREADY.setData({ module: modName })
163
135
  }
164
136
  try {
165
- // all essential modules will use hard-coded value, as config won't be loaded yet
166
- const timeout = this.getConfig('moduleLoadTimeout') ?? 10000
137
+ const timeout = this.app.getConfig('moduleLoadTimeout') ?? 10000
167
138
  await Promise.race([
168
139
  instance.onReady(),
169
- new Promise((resolve, reject) => setTimeout(() => reject(new Error(`${modName} load exceeded timeout (${timeout})`)), timeout))
140
+ new Promise((resolve, reject) => setTimeout(() => reject(this.app.errors.DEP_TIMEOUT.setData({ module: modName, timeout })), timeout))
170
141
  ])
171
142
  this.instances[modName] = instance
172
143
  await this.moduleLoadedHook.invoke(null, instance)
173
144
  return instance
174
145
  } catch (e) {
175
- await this.moduleLoadedHook.invoke(e)
146
+ await this.moduleLoadedHook.invoke(e, { name: modName })
176
147
  throw e
177
148
  }
178
149
  }
179
150
 
180
151
  /**
181
- * Loads a list of Adapt modules. Should not need to be called directly.
182
- * @param {Array<string>} modules Module names to load
183
- * @param {Object} [options] Loading options
184
- * @param {boolean} [options.force=false] If true, logs errors and continues loading other modules when a module fails. If false, throws a DependencyError on first failure.
185
- * @return {Promise<void>} Resolves when all modules have loaded (or failed to load in force mode)
186
- * @throws {DependencyError} When a module fails to load and options.force is not true
152
+ * Loads Adapt modules. If no list is provided, loads all configured dependencies.
153
+ * @param {Array<string>} [modules] Module names to load (defaults to all dependencies)
154
+ * @return {Promise<void>} Resolves when all modules have loaded or failed
155
+ * @throws {Error} When any module throws a fatal error (error.isFatal or error.cause.isFatal)
187
156
  */
188
- async loadModules (modules, options = {}) {
157
+ async loadModules (modules = Object.values(this.configs).map(c => c.name)) {
189
158
  await Promise.all(modules.map(async m => {
190
159
  try {
191
160
  await this.loadModule(m)
192
161
  } catch (e) {
193
- if (options.force !== true) {
194
- const error = new Error(`Failed to load '${m}'`)
195
- error.name = 'DependencyError'
196
- error.cause = e
197
- throw error
162
+ if (e.isFatal || e.cause?.isFatal) {
163
+ throw e
198
164
  }
199
- this.logError(`Failed to load '${m}',`, e)
165
+ this.log('error', `Failed to load '${m}',`, e)
200
166
  const deps = this.peerDependencies[m]
201
- if (deps && deps.length) {
202
- this.logError('The following modules are peer dependencies, and may not work:')
203
- deps.forEach(d => this.logError(`- ${d}`))
167
+ if (deps?.length) {
168
+ this.log('error', 'The following modules are peer dependencies, and may not work:')
169
+ deps.forEach(d => this.log('error', `- ${d}`))
204
170
  }
205
171
  this.failedModules.push(m)
206
172
  }
@@ -217,14 +183,12 @@ class DependencyLoader {
217
183
  if (!this._configsLoaded) {
218
184
  await this.configsLoadedHook.onInvoke()
219
185
  }
220
- const longPrefix = 'adapt-authoring-'
221
- if (!modName.startsWith(longPrefix)) modName = `adapt-authoring-${modName}`
186
+ if (!modName.startsWith('adapt-authoring-')) modName = `adapt-authoring-${modName}`
222
187
  if (!this.configs[modName]) {
223
- throw new Error(`Missing required module '${modName}'`)
188
+ throw this.app.errors.DEP_MISSING.setData({ module: modName })
224
189
  }
225
- const DependencyError = new Error(`Dependency '${modName}' failed to load`)
226
190
  if (this.failedModules.includes(modName)) {
227
- throw DependencyError
191
+ throw this.app.errors.DEP_FAILED.setData({ module: modName })
228
192
  }
229
193
  const instance = this.instances[modName]
230
194
  if (instance) {
@@ -232,8 +196,9 @@ class DependencyLoader {
232
196
  }
233
197
  return new Promise((resolve, reject) => {
234
198
  this.moduleLoadedHook.tap((error, instance) => {
235
- if (error) return reject(DependencyError)
236
- if (instance?.name === modName) resolve(instance)
199
+ if (instance?.name !== modName) return
200
+ if (error) return reject(this.app.errors.DEP_FAILED.setData({ module: modName }))
201
+ resolve(instance)
237
202
  })
238
203
  })
239
204
  }
@@ -243,9 +208,8 @@ class DependencyLoader {
243
208
  * @param {AbstractModule} instance The last loaded instance
244
209
  */
245
210
  logProgress (error, instance) {
246
- if (error) {
247
- return
248
- }
211
+ if (error) return
212
+
249
213
  const toShort = names => names.map(n => n.replace('adapt-authoring-', '')).join(', ')
250
214
  const loaded = []
251
215
  const notLoaded = []
@@ -264,42 +228,21 @@ class DependencyLoader {
264
228
  ].filter(Boolean).join(', '))
265
229
 
266
230
  if (progress === 100) {
267
- const initTimes = Object.entries(this.instances)
268
- .sort((a, b) => a[1].initTime < b[1].initTime ? -1 : a[1].initTime > b[1].initTime ? 1 : 0)
269
- .reduce((memo, [modName, instance]) => Object.assign(memo, { [modName]: instance.initTime }), {})
231
+ const initTimes = Object.fromEntries(
232
+ Object.entries(this.instances)
233
+ .sort(([, a], [, b]) => a.initTime - b.initTime)
234
+ .map(([name, inst]) => [name, inst.initTime])
235
+ )
270
236
  this.log('verbose', initTimes)
271
237
  }
272
238
  }
273
239
 
274
240
  /**
275
- * Logs a message using the app logger if available, otherwise falls back to console.log
241
+ * Logs a message using the app logger
276
242
  * @param {...*} args Arguments to be logged
277
243
  */
278
244
  log (level, ...args) {
279
- if (this.app.logger?._isReady) {
280
- this.app.logger.log(level, this.name, ...args)
281
- } else {
282
- console.log(...args)
283
- }
284
- }
285
-
286
- /**
287
- * Logs an error message using the app logger if available, otherwise falls back to console.log
288
- * @param {...*} args Arguments to be logged
289
- */
290
- logError (...args) {
291
- this.log('error', ...args)
292
- }
293
-
294
- /**
295
- * Retrieves a configuration value from this module's config
296
- * @param {string} key - The configuration key to retrieve
297
- * @returns {*|undefined} The configuration value if config is ready, undefined otherwise
298
- */
299
- getConfig (key) {
300
- if (this.app.config?._isReady) {
301
- return this.app.config.get(`adapt-authoring-core.${key}`)
302
- }
245
+ this.app.logger?.log(level, this.name, ...args)
303
246
  }
304
247
  }
305
248
 
package/lib/Errors.js ADDED
@@ -0,0 +1,50 @@
1
+ import AdaptError from './AdaptError.js'
2
+ import fs from 'node:fs'
3
+ import { globSync } from 'glob'
4
+
5
+ /**
6
+ * Loads and stores all error definitions for the application. Errors are accessed via human-readable error codes for better readability when thrown in code.
7
+ * @memberof core
8
+ */
9
+ class Errors {
10
+ /**
11
+ * @param {Object} options
12
+ * @param {Object} options.dependencies Key/value map of dependency configs (each with a rootDir)
13
+ * @param {Function} [options.log] Optional logging function (level, id, ...args)
14
+ */
15
+ constructor ({ dependencies, log } = {}) {
16
+ const errorDefs = {}
17
+ for (const d of Object.values(dependencies)) {
18
+ const files = globSync('errors/*.json', { cwd: d.rootDir, absolute: true })
19
+ for (const f of files) {
20
+ try {
21
+ const contents = JSON.parse(fs.readFileSync(f))
22
+ Object.entries(contents).forEach(([k, v]) => {
23
+ if (errorDefs[k]) {
24
+ log?.('warn', 'errors', `error code '${k}' already defined`)
25
+ return
26
+ }
27
+ errorDefs[k] = v
28
+ })
29
+ } catch (e) {
30
+ log?.('warn', 'errors', e.message)
31
+ }
32
+ }
33
+ }
34
+ Object.entries(errorDefs)
35
+ .sort()
36
+ .forEach(([k, { description, statusCode, isFatal, data }]) => {
37
+ Object.defineProperty(this, k, {
38
+ get: () => {
39
+ const metadata = { description }
40
+ if (isFatal) metadata.isFatal = true
41
+ if (data) metadata.data = data
42
+ return new AdaptError(k, statusCode, metadata)
43
+ },
44
+ enumerable: true
45
+ })
46
+ })
47
+ }
48
+ }
49
+
50
+ export default Errors
package/lib/Hook.js CHANGED
@@ -1,4 +1,3 @@
1
- import _ from 'lodash'
2
1
  /**
3
2
  * Allows observers to tap into to a specific piece of code, and execute their own arbitrary code
4
3
  * @memberof core
@@ -43,7 +42,7 @@ class Hook {
43
42
  * @param {*} scope Sets the scope of the observer
44
43
  */
45
44
  tap (observer, scope) {
46
- if (_.isFunction(observer)) this._hookObservers.push(observer.bind(scope))
45
+ if (typeof observer === 'function') this._hookObservers.push(observer.bind(scope))
47
46
  }
48
47
 
49
48
  /**
@@ -78,7 +77,7 @@ class Hook {
78
77
  data = await Promise.all(this._hookObservers.map(o => o(...args)))
79
78
  } else {
80
79
  // if not mutable, send a deep copy of the args to avoid any meddling
81
- for (const o of this._hookObservers) data = await o(...this._options.mutable ? args : args.map(a => _.cloneDeep(a)))
80
+ for (const o of this._hookObservers) data = await o(...this._options.mutable ? args : args.map(a => structuredClone(a)))
82
81
  }
83
82
  } catch (e) {
84
83
  error = e