adapt-schemas 1.2.1 → 2.0.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # adapt-schemas
2
2
 
3
- A standalone JSON Schema library for the Adapt framework stack. Load schemas from plugin folders via glob patterns, validate JSON data, and extract defaults including `_globals` from course schemas.
3
+ A standalone JSON Schema library for the Adapt framework stack. Load schemas from plugin folders via glob patterns and validate JSON data with automatic defaults application via AJV.
4
4
 
5
5
  ## Installation
6
6
 
@@ -23,13 +23,10 @@ await library.loadSchemas('**/schema/*.schema.json', {
23
23
  ignore: ['**/node_modules/**']
24
24
  })
25
25
 
26
- // Validate data against a schema
26
+ // Validate data against a schema (defaults are applied automatically)
27
27
  const validatedData = await library.validate('course', {
28
28
  title: 'My Course'
29
29
  })
30
-
31
- // Get _globals defaults from the course schema
32
- const globals = await library.getGlobalsDefaults('course')
33
30
  ```
34
31
 
35
32
  ## API Reference
@@ -110,22 +107,6 @@ const validated = await library.validate('course', inputData, {
110
107
  })
111
108
  ```
112
109
 
113
- ##### `getSchemaDefaults(schemaName)`
114
- Returns all defaults as a structured object.
115
-
116
- ```javascript
117
- const defaults = await library.getSchemaDefaults('course')
118
- // { title: 'Untitled', _globals: { ... } }
119
- ```
120
-
121
- ##### `getGlobalsDefaults(schemaName)`
122
- Extracts `_globals` defaults from a schema.
123
-
124
- ```javascript
125
- const globals = await library.getGlobalsDefaults('course')
126
- // { _accessibility: { _isEnabled: true, ... }, _extensions: { ... } }
127
- ```
128
-
129
110
  ##### `getSchemaNames()`
130
111
  Returns list of all registered schema names.
131
112
 
package/lib/Schema.js CHANGED
@@ -18,7 +18,7 @@ class Schema extends EventEmitter {
18
18
  * @param {Object} options.xssWhitelist XSS whitelist configuration
19
19
  * @param {SchemaLibrary} options.schemaLibrary Reference to the parent library
20
20
  */
21
- constructor ({ enableCache, filePath, validator, xssWhitelist, schemaLibrary }) {
21
+ constructor ({ enableCache, filePath, validatorWithDefaults, validator, xssWhitelist, schemaLibrary }) {
22
22
  super()
23
23
 
24
24
  /**
@@ -28,11 +28,17 @@ class Schema extends EventEmitter {
28
28
  this.built = undefined
29
29
 
30
30
  /**
31
- * The compiled schema validation function
31
+ * Compiled validation function
32
32
  * @type {function}
33
33
  */
34
34
  this.compiled = undefined
35
35
 
36
+ /**
37
+ * Compiled validation function with useDefaults enabled
38
+ * @type {function}
39
+ */
40
+ this.compiledWithDefaults = undefined
41
+
36
42
  /**
37
43
  * Whether caching is enabled for this schema
38
44
  * @type {boolean}
@@ -76,7 +82,13 @@ class Schema extends EventEmitter {
76
82
  this.name = undefined
77
83
 
78
84
  /**
79
- * Reference to the Ajv validator instance
85
+ * Ajv instance with useDefaults enabled
86
+ * @type {Ajv}
87
+ */
88
+ this.validatorWithDefaults = validatorWithDefaults
89
+
90
+ /**
91
+ * Ajv instance without useDefaults
80
92
  * @type {Ajv}
81
93
  */
82
94
  this.validator = validator
@@ -143,8 +155,8 @@ class Schema extends EventEmitter {
143
155
  })
144
156
  }
145
157
 
146
- if (this.validator.validateSchema(this.raw)?.errors) {
147
- const errors = this.validator.errors
158
+ if (this.validatorWithDefaults.validateSchema(this.raw)?.errors) {
159
+ const errors = this.validatorWithDefaults.errors
148
160
  .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
149
161
 
150
162
  if (errors.length) {
@@ -202,6 +214,7 @@ class Schema extends EventEmitter {
202
214
  this.built = built
203
215
 
204
216
  if (options.compile !== false) {
217
+ this.compiledWithDefaults = await this.validatorWithDefaults.compileAsync(built)
205
218
  this.compiled = await this.validator.compileAsync(built)
206
219
  }
207
220
 
@@ -275,16 +288,18 @@ class Schema extends EventEmitter {
275
288
  */
276
289
  validate (dataToValidate, options = {}) {
277
290
  const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
278
- const data = _.defaults(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
291
+ const data = _.cloneDeep(dataToValidate)
292
+ let compiled = opts.useDefaults ? this.compiledWithDefaults : this.compiled
279
293
 
280
- if (!this.compiled) {
294
+ if (!compiled) {
281
295
  this.emit('warning', `No compiled function for ${this.name}, compiling now`)
282
- this.validator.compile(this.built)
296
+ const v = opts.useDefaults ? this.validatorWithDefaults : this.validator
297
+ compiled = v.compile(this.built)
283
298
  }
284
299
 
285
- this.compiled(data)
300
+ compiled(data)
286
301
 
287
- const errors = this.compiled.errors && this.compiled.errors
302
+ const errors = compiled.errors && compiled.errors
288
303
  .filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
289
304
  .map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
290
305
  .reduce((s, e) => `${s}${e}, `, '')
@@ -358,22 +373,6 @@ class Schema extends EventEmitter {
358
373
  }
359
374
  }
360
375
 
361
- /**
362
- * Returns all schema defaults as a correctly structured object
363
- * @param {Object} schema Schema to extract defaults from
364
- * @returns {Object} The defaults object
365
- */
366
- getObjectDefaults (schema) {
367
- schema = schema ?? this.built
368
- const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
369
-
370
- return _.mapValues(props, s => {
371
- return s.type === 'object' && s.properties
372
- ? this.getObjectDefaults(s)
373
- : s.default
374
- })
375
- }
376
-
377
376
  /**
378
377
  * Walks the built schema alongside data, returning fields matching a predicate.
379
378
  * @param {Object} data - The data object to walk
package/lib/Schemas.js CHANGED
@@ -57,11 +57,7 @@ class Schemas extends EventEmitter {
57
57
  this.options.xssWhitelist
58
58
  )
59
59
 
60
- /**
61
- * Reference to the Ajv instance
62
- * @type {Ajv}
63
- */
64
- this.validator = new Ajv({
60
+ const ajvOptions = {
65
61
  addUsedSchema: false,
66
62
  allErrors: true,
67
63
  allowUnionTypes: true,
@@ -70,7 +66,19 @@ class Schemas extends EventEmitter {
70
66
  strict: false,
71
67
  verbose: true,
72
68
  keywords: Keywords.all()
73
- })
69
+ }
70
+
71
+ /**
72
+ * Ajv instance
73
+ * @type {Ajv}
74
+ */
75
+ this.validator = new Ajv({ ...ajvOptions })
76
+
77
+ /**
78
+ * Ajv instance with useDefaults enabled (applies schema defaults during validation)
79
+ * @type {Ajv}
80
+ */
81
+ this.validatorWithDefaults = new Ajv({ ...ajvOptions, useDefaults: true })
74
82
 
75
83
  this.addStringFormats({
76
84
  'date-time': /[A-Za-z0-9:+()]+/,
@@ -119,7 +127,9 @@ class Schemas extends EventEmitter {
119
127
  if (isUnsafe) {
120
128
  this.emit('warning', `Unsafe RegExp for format '${name}' (${re}), using default`)
121
129
  }
122
- this.validator.addFormat(name, isUnsafe ? /.*/ : re)
130
+ const format = isUnsafe ? /.*/ : re
131
+ this.validatorWithDefaults.addFormat(name, format)
132
+ this.validator.addFormat(name, format)
123
133
  })
124
134
  }
125
135
 
@@ -131,13 +141,15 @@ class Schemas extends EventEmitter {
131
141
  */
132
142
  addKeyword (definition, options = {}) {
133
143
  try {
134
- if (this.validator.getKeyword(definition.keyword)) {
135
- if (options.override !== true) {
136
- throw new SchemaError('KEYWORD_EXISTS', 'Keyword already exists')
144
+ for (const v of [this.validatorWithDefaults, this.validator]) {
145
+ if (v.getKeyword(definition.keyword)) {
146
+ if (options.override !== true) {
147
+ throw new SchemaError('KEYWORD_EXISTS', 'Keyword already exists')
148
+ }
149
+ v.removeKeyword(definition.keyword)
137
150
  }
138
- this.validator.removeKeyword(definition.keyword)
151
+ v.addKeyword(definition)
139
152
  }
140
- this.validator.addKeyword(definition)
141
153
  } catch (e) {
142
154
  this.emit('warning', `Failed to define keyword '${definition.keyword}', ${e}`)
143
155
  }
@@ -245,6 +257,7 @@ class Schemas extends EventEmitter {
245
257
  const schema = new Schema({
246
258
  enableCache: options.enableCache ?? this.options.enableCache,
247
259
  filePath,
260
+ validatorWithDefaults: this.validatorWithDefaults,
248
261
  validator: this.validator,
249
262
  xssWhitelist: this.xssWhitelist,
250
263
  schemaLibrary: this,
@@ -316,27 +329,6 @@ class Schemas extends EventEmitter {
316
329
  return schema.built
317
330
  }
318
331
 
319
- /**
320
- * Returns schema defaults as a structured object
321
- * @param {string} schemaName The schema name
322
- * @returns {Promise<Object>} The defaults object
323
- */
324
- async getSchemaDefaults (schemaName) {
325
- const schema = await this.getSchema(schemaName)
326
- return schema.getObjectDefaults()
327
- }
328
-
329
- /**
330
- * Extracts _globals defaults from the course schema
331
- * @param {string} schemaName Schema name (defaults to 'course')
332
- * @returns {Promise<Object>} The _globals defaults
333
- */
334
- async getGlobalsDefaults (schemaName = 'course') {
335
- const schema = await this.getSchema(schemaName)
336
- const defaults = schema.getObjectDefaults()
337
- return defaults._globals || {}
338
- }
339
-
340
332
  /**
341
333
  * Returns list of all registered schema names
342
334
  * @returns {string[]}
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "type": "git",
5
5
  "url": "https://github.com/cgkineo/adapt-schemas"
6
6
  },
7
- "version": "1.2.1",
7
+ "version": "2.0.0",
8
8
  "description": "Standalone JSON Schema library for the Adapt framework",
9
9
  "type": "module",
10
10
  "main": "index.js",
package/test.js CHANGED
@@ -119,6 +119,52 @@ async function setupTestSchemas () {
119
119
  }
120
120
  }
121
121
 
122
+ // Create a config schema with nested defaults (mimics trickle pattern)
123
+ const configSchema = {
124
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
125
+ $anchor: 'config',
126
+ $merge: {
127
+ source: { $ref: 'base' },
128
+ with: {
129
+ properties: {
130
+ _trickle: {
131
+ type: 'object',
132
+ properties: {
133
+ _isEnabled: {
134
+ type: 'boolean',
135
+ default: true
136
+ },
137
+ _scrollDuration: {
138
+ type: 'number',
139
+ default: 500
140
+ },
141
+ _button: {
142
+ type: 'object',
143
+ default: {},
144
+ properties: {
145
+ _isEnabled: {
146
+ type: 'boolean',
147
+ default: true
148
+ },
149
+ _styleBeforeCompletion: {
150
+ type: 'string',
151
+ default: 'hidden'
152
+ }
153
+ }
154
+ },
155
+ _completionOrder: {
156
+ type: 'array',
157
+ items: { type: 'number' },
158
+ default: [1, 2, 3]
159
+ }
160
+ },
161
+ required: ['_scrollDuration']
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
122
168
  // Create a patch schema that extends course
123
169
  const coursePatchSchema = {
124
170
  $schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -158,6 +204,10 @@ async function setupTestSchemas () {
158
204
  path.join(testSchemaDir, 'component.schema.json'),
159
205
  JSON.stringify(componentSchema, null, 2)
160
206
  )
207
+ await fs.writeFile(
208
+ path.join(testSchemaDir, 'config.schema.json'),
209
+ JSON.stringify(configSchema, null, 2)
210
+ )
161
211
  await fs.writeFile(
162
212
  path.join(testSchemaDir, 'course-extension.schema.json'),
163
213
  JSON.stringify(coursePatchSchema, null, 2)
@@ -196,26 +246,16 @@ async function runTests () {
196
246
  const courseBuilt = await library.getBuiltSchema('course')
197
247
  console.log(` ✓ Course schema has properties: ${Object.keys(courseBuilt.properties).join(', ')}\n`)
198
248
 
199
- // Test 4: Get defaults
200
- console.log('Test 4: Get schema defaults')
201
- const courseDefaults = await library.getSchemaDefaults('course')
202
- console.log(' ✓ Course defaults:', JSON.stringify(courseDefaults, null, 4).split('\n').map(l => ' ' + l).join('\n'), '\n')
203
-
204
- // Test 5: Get _globals defaults
205
- console.log('Test 5: Get _globals defaults')
206
- const globalsDefaults = await library.getGlobalsDefaults('course')
207
- console.log(' ✓ _globals defaults:', JSON.stringify(globalsDefaults, null, 4).split('\n').map(l => ' ' + l).join('\n'), '\n')
208
-
209
- // Test 6: Validate data
210
- console.log('Test 6: Validate data')
249
+ // Test 4: Validate data
250
+ console.log('Test 4: Validate data')
211
251
  const validData = await library.validate('course', {
212
252
  title: 'My Course'
213
253
  })
214
254
  console.log(` ✓ Validated data has title: "${validData.title}"`)
215
255
  console.log(` ✓ Default description applied: "${validData.description}"\n`)
216
256
 
217
- // Test 7: Validate with error (missing required field without defaults)
218
- console.log('Test 7: Validation error handling')
257
+ // Test 5: Validate with error (missing required field without defaults)
258
+ console.log('Test 5: Validation error handling')
219
259
  try {
220
260
  await library.validate('course', {
221
261
  // Missing required title - disable defaults to trigger required error
@@ -227,8 +267,8 @@ async function runTests () {
227
267
  console.log(` ✓ Error message: ${e.message}\n`)
228
268
  }
229
269
 
230
- // Test 7b: Validate with type error
231
- console.log('Test 7b: Type validation error')
270
+ // Test 5b: Validate with type error
271
+ console.log('Test 5b: Type validation error')
232
272
  try {
233
273
  await library.validate('course', {
234
274
  title: 12345 // Should be string, not number
@@ -238,16 +278,16 @@ async function runTests () {
238
278
  console.log(` ✓ Caught type validation error: ${e.code}\n`)
239
279
  }
240
280
 
241
- // Test 8: Schema inheritance
242
- console.log('Test 8: Schema inheritance')
281
+ // Test 6: Schema inheritance
282
+ console.log('Test 6: Schema inheritance')
243
283
  const componentBuilt = await library.getBuiltSchema('component')
244
284
  const hasInheritedBody = componentBuilt.properties.body !== undefined
245
285
  const hasOwnComponent = componentBuilt.properties._component !== undefined
246
286
  console.log(` ✓ Component has inherited 'body' property: ${hasInheritedBody}`)
247
287
  console.log(` ✓ Component has own '_component' property: ${hasOwnComponent}\n`)
248
288
 
249
- // Test 9: Schema info
250
- console.log('Test 9: Schema info')
289
+ // Test 7: Schema info
290
+ console.log('Test 7: Schema info')
251
291
  const info = library.getSchemaInfo()
252
292
  console.log(' ✓ Schema info:')
253
293
  Object.entries(info).forEach(([name, details]) => {
@@ -255,8 +295,8 @@ async function runTests () {
255
295
  })
256
296
  console.log('')
257
297
 
258
- // Test 10: Events
259
- console.log('Test 10: Event handling')
298
+ // Test 8: Events
299
+ console.log('Test 8: Event handling')
260
300
  library.on('schemaRegistered', (name) => {
261
301
  console.log(` ✓ Event received: schemaRegistered (${name})`)
262
302
  })
@@ -274,8 +314,8 @@ async function runTests () {
274
314
  await library.registerSchema(newSchemaPath)
275
315
  console.log('')
276
316
 
277
- // Test 11: Schema.walk
278
- console.log('Test 11: Schema.walk')
317
+ // Test 9: Schema.walk
318
+ console.log('Test 9: Schema.walk')
279
319
  const courseSchema = await library.getSchema('course')
280
320
  const walkData = {
281
321
  title: 'Test',
@@ -295,6 +335,84 @@ async function runTests () {
295
335
  console.log(` ✓ Includes top-level field: ${hasTitle}`)
296
336
  console.log(` ✓ Includes nested field: ${hasNested}\n`)
297
337
 
338
+ // Test 10: Deep defaults on partial nested objects
339
+ console.log('Test 10: Deep defaults on partial nested objects')
340
+ const configData = await library.validate('config', {
341
+ _trickle: { _isEnabled: false }
342
+ })
343
+ const hasScrollDuration = configData._trickle._scrollDuration === 500
344
+ const hasButtonDefaults = configData._trickle._button?._styleBeforeCompletion === 'hidden'
345
+ const preservedExplicit = configData._trickle._isEnabled === false
346
+ console.log(` ✓ Nested default _scrollDuration applied: ${hasScrollDuration}`)
347
+ console.log(` ✓ Deep nested _button defaults applied: ${hasButtonDefaults}`)
348
+ console.log(` ✓ Explicitly set _isEnabled preserved: ${preservedExplicit}`)
349
+ if (!hasScrollDuration || !hasButtonDefaults || !preservedExplicit) {
350
+ throw new Error('Deep defaults not applied correctly to partial nested objects')
351
+ }
352
+ console.log('')
353
+
354
+ // Test 11: Array defaults replaced, not merged
355
+ console.log('Test 11: Array defaults are replaced, not merged')
356
+ const arrayData = await library.validate('config', {
357
+ _trickle: { _completionOrder: [10, 20] }
358
+ })
359
+ const arrayNotMerged = JSON.stringify(arrayData._trickle._completionOrder) === '[10,20]'
360
+ console.log(` ✓ Array preserved as [10,20], not merged with default: ${arrayNotMerged}`)
361
+ if (!arrayNotMerged) {
362
+ throw new Error(`Array was merged with defaults: got ${JSON.stringify(arrayData._trickle._completionOrder)}, expected [10,20]`)
363
+ }
364
+
365
+ const arrayDefaultApplied = await library.validate('config', {
366
+ _trickle: { _isEnabled: false }
367
+ })
368
+ const defaultArrayApplied = JSON.stringify(arrayDefaultApplied._trickle._completionOrder) === '[1,2,3]'
369
+ console.log(` ✓ Missing array gets default [1,2,3]: ${defaultArrayApplied}`)
370
+ if (!defaultArrayApplied) {
371
+ throw new Error(`Default array not applied: got ${JSON.stringify(arrayDefaultApplied._trickle._completionOrder)}`)
372
+ }
373
+ console.log('')
374
+
375
+ // Test 12: useDefaults: false produces no defaults
376
+ console.log('Test 12: useDefaults: false produces no defaults')
377
+ const noDefaultsData = await library.validate('content', {
378
+ body: 'Hello'
379
+ }, { useDefaults: false, ignoreRequired: true })
380
+ const noIsOptional = noDefaultsData._isOptional === undefined
381
+ console.log(` ✓ _isOptional not filled in: ${noIsOptional}`)
382
+ if (!noIsOptional) {
383
+ throw new Error(`useDefaults: false still applied defaults: _isOptional = ${noDefaultsData._isOptional}`)
384
+ }
385
+
386
+ const withDefaultsData = await library.validate('content', {
387
+ body: 'Hello'
388
+ }, { useDefaults: true, ignoreRequired: true })
389
+ const hasIsOptional = withDefaultsData._isOptional === false
390
+ console.log(` ✓ _isOptional filled in with useDefaults: true: ${hasIsOptional}`)
391
+ if (!hasIsOptional) {
392
+ throw new Error(`useDefaults: true did not apply defaults: _isOptional = ${withDefaultsData._isOptional}`)
393
+ }
394
+ console.log('')
395
+
396
+ // Test 13: Partial nested object with required+default field (issue #21)
397
+ console.log('Test 13: Required+default field in partial nested object (issue #21)')
398
+ try {
399
+ const issueData = await library.validate('config', {
400
+ _trickle: { _isEnabled: false }
401
+ })
402
+ const hasRequired = issueData._trickle._scrollDuration === 500
403
+ console.log(' ✓ Validation passed (did not throw): true')
404
+ console.log(` ✓ Required+default _scrollDuration applied: ${hasRequired}`)
405
+ if (!hasRequired) {
406
+ throw new Error('Required+default field _scrollDuration was not applied')
407
+ }
408
+ } catch (e) {
409
+ if (e.code === 'VALIDATION_FAILED') {
410
+ throw new Error(`Issue #21 regression: validation failed on partial nested object: ${e.data?.errors || e.message}`)
411
+ }
412
+ throw e
413
+ }
414
+ console.log('')
415
+
298
416
  console.log('=== All tests passed! ===')
299
417
  } finally {
300
418
  if (!hasSpecifiedPath) {