@squiz/dx-json-schema-lib 1.82.2 → 1.82.4

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.
@@ -1,4 +1,5 @@
1
1
  import JSONQuery, { Input } from '@sagold/json-query';
2
+ import cloneDeep from 'lodash.clonedeep';
2
3
 
3
4
  import DxComponentInputSchema from './manifest/v1/DxComponentInputSchema.json';
4
5
  import DxComponentIcons from './manifest/v1/DxComponentIcons.json';
@@ -72,7 +73,37 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
72
73
  schema = core.resolveRef(schema);
73
74
  callback(schema, data, pointer);
74
75
  } else {
75
- defaultConfig.each(core, data, callback, schema, pointer);
76
+ // CRITICAL FIX: Preserve data integrity during allOf schema traversal
77
+ // Only apply protection for allOf schemas that contain arrays with primitive types
78
+ if (schema?.allOf && Array.isArray(schema.allOf) && this.hasArraysWithPrimitiveTypes(schema)) {
79
+ // Get the original input data stored at the beginning of resolveInput
80
+ const originalInputData = (this as any).__originalInputData;
81
+
82
+ // Create a deep clone to prevent Draft library from corrupting original data
83
+ const safeDataForTraversal = JSON.parse(JSON.stringify(data));
84
+
85
+ // Use the safe clone for traversal, but preserve callbacks with original data
86
+ defaultConfig.each(
87
+ core,
88
+ safeDataForTraversal,
89
+ (resolvedSchema, corruptedValue, resolvedPointer) => {
90
+ // Only preserve data for paths that contain arrays of SquizLink/SquizImage
91
+ if (this.isProtectedPrimitivePath(resolvedSchema, resolvedPointer)) {
92
+ const originalValueAtPointer = originalInputData
93
+ ? this.getValueAtPointer(originalInputData, resolvedPointer)
94
+ : corruptedValue;
95
+ callback(resolvedSchema, originalValueAtPointer, resolvedPointer);
96
+ } else {
97
+ // Use normal resolution for non-primitive arrays
98
+ callback(resolvedSchema, corruptedValue, resolvedPointer);
99
+ }
100
+ },
101
+ schema,
102
+ pointer,
103
+ );
104
+ } else {
105
+ defaultConfig.each(core, data, callback, schema, pointer);
106
+ }
76
107
  }
77
108
  },
78
109
  },
@@ -103,6 +134,42 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
103
134
  return this.schema.compileSchema(fullValidationSchema);
104
135
  }
105
136
 
137
+ /**
138
+ * Recursively check if a schema contains allOf combinators that could cause mutation
139
+ */
140
+ private hasAllOfCombinator(schema: any, visited: WeakSet<object> = new WeakSet()): boolean {
141
+ if (!schema || typeof schema !== 'object') return false;
142
+
143
+ // Prevent infinite recursion from circular references
144
+ if (visited.has(schema)) return false;
145
+ visited.add(schema);
146
+
147
+ // Direct allOf check
148
+ if (schema.allOf) return true;
149
+
150
+ // Check in properties
151
+ if (schema.properties) {
152
+ for (const prop of Object.values(schema.properties)) {
153
+ if (this.hasAllOfCombinator(prop, visited)) return true;
154
+ }
155
+ }
156
+
157
+ // Check in items (arrays)
158
+ if (schema.items && this.hasAllOfCombinator(schema.items, visited)) return true;
159
+
160
+ // Check in nested combinators
161
+ if (schema.oneOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true;
162
+ if (schema.anyOf?.some((subSchema: any) => this.hasAllOfCombinator(subSchema, visited))) return true;
163
+ if (schema.not && this.hasAllOfCombinator(schema.not, visited)) return true;
164
+
165
+ // Check in conditional schemas (if/then/else)
166
+ if (schema.if && this.hasAllOfCombinator(schema.if, visited)) return true;
167
+ if (schema.then && this.hasAllOfCombinator(schema.then, visited)) return true;
168
+ if (schema.else && this.hasAllOfCombinator(schema.else, visited)) return true;
169
+
170
+ return false;
171
+ }
172
+
106
173
  /**
107
174
  * Validate an input value against a specified schema
108
175
  * @throws {SchemaValidationError} if the input is invalid
@@ -110,7 +177,13 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
110
177
  */
111
178
  public validateInput(input: unknown, inputSchema: JSONSchema = this.schema.rootSchema): true | never {
112
179
  inputSchema = this.schema.compileSchema(inputSchema);
113
- const errors = this.schema.validate(input, inputSchema);
180
+
181
+ // Only clone if schema contains allOf combinators that could cause mutation
182
+ // This optimizes performance by avoiding unnecessary cloning for simple schemas
183
+ const needsCloning = this.hasAllOfCombinator(inputSchema);
184
+ const inputToValidate = needsCloning ? cloneDeep(input) : input;
185
+
186
+ const errors = this.schema.validate(inputToValidate, inputSchema);
114
187
  return processValidationResult(errors);
115
188
  }
116
189
 
@@ -121,17 +194,50 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
121
194
  * @returns the input object with all resolvable shapes resolved
122
195
  */
123
196
  public async resolveInput(input: Input, inputSchema: JSONSchema, ctx: ResolverContext = {}) {
197
+ // Store the original input data to prevent corruption during nested allOf processing
198
+ (this as any).__originalInputData = JSON.parse(JSON.stringify(input));
199
+
124
200
  const setters: Array<Promise<(input: Input) => Input>> = [];
125
201
  this.schema.each(
126
202
  input,
127
203
  async (schema, value, pointer) => {
204
+ // Debug logging for investigation of resolution path
205
+ // console.debug('JSONSchemaService.each', {
206
+ // pointer,
207
+ // title: (schema as any)?.title,
208
+ // type: (schema as any)?.type,
209
+ // value,
210
+ // });
211
+ // Guard: if value is an array of already-resolved primitives (e.g., SquizLink/SquizImage), skip
212
+ if (Array.isArray(value)) {
213
+ const isLinkLike = (v: any) =>
214
+ v && typeof v === 'object' && typeof v.text === 'string' && typeof v.url === 'string';
215
+ const isImageLike = (v: any) =>
216
+ v &&
217
+ typeof v === 'object' &&
218
+ typeof v.name === 'string' &&
219
+ v.imageVariations &&
220
+ typeof v.imageVariations === 'object';
221
+ const allResolvedPrimitives = value.every((v) => isLinkLike(v) || isImageLike(v));
222
+ if (allResolvedPrimitives) {
223
+ return;
224
+ }
225
+ }
128
226
  // Bug in library for Array item schemas which won't resolve the oneOf schema
129
227
  if (Array.isArray(schema?.oneOf)) {
130
228
  const oldSchema = schema;
131
229
  schema = this.schema.resolveOneOf(value, schema);
132
230
  schema.oneOfSchema = oldSchema;
133
231
  }
232
+
233
+ // Skip resolution if this is already a complete SquizLink or SquizImage
234
+ // This prevents primitive types from being incorrectly processed when they're already in final form
235
+ if (this.isAlreadyResolvedPrimitive(schema, value)) {
236
+ return;
237
+ }
238
+
134
239
  if (!this.typeResolver.isResolvableSchema(schema)) return;
240
+
135
241
  // If its a resolvable schema, it should exist in a oneOf array with other schemas
136
242
  // Including a primitive schema
137
243
  const allPossibleSchemaTitles: Array<string> = schema.oneOfSchema.oneOf.map((o: JSONSchema) =>
@@ -140,6 +246,7 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
140
246
 
141
247
  const primitiveSchema = allPossibleSchemaTitles.find((title) => this.typeResolver.isPrimitiveType(title));
142
248
  if (!primitiveSchema) return;
249
+
143
250
  const resolver = this.typeResolver.tryGetResolver(primitiveSchema, schema as any);
144
251
  if (!resolver) return;
145
252
  const setResolvedData = Promise.resolve()
@@ -163,6 +270,128 @@ export class JSONSchemaService<P extends AnyPrimitiveType, R extends AnyResolvab
163
270
  if (potentialResolutionErrors.length) {
164
271
  throw new Error(`Error(s) occurred when resolving JSON:\n${potentialResolutionErrors.join('\n')}`);
165
272
  }
273
+
274
+ // Clean up stored original data to prevent memory leaks
275
+ delete (this as any).__originalInputData;
276
+
166
277
  return input;
167
278
  }
279
+
280
+ /**
281
+ * Check if the allOf schema contains arrays with primitive types (SquizLink/SquizImage)
282
+ * Used to determine if we need to apply data preservation
283
+ */
284
+ private hasArraysWithPrimitiveTypes(schema: any): boolean {
285
+ if (!schema?.allOf || !Array.isArray(schema.allOf)) return false;
286
+
287
+ const checkForPrimitiveArrays = (obj: any): boolean => {
288
+ if (!obj || typeof obj !== 'object') return false;
289
+
290
+ // Check if this is an array with SquizLink/SquizImage items
291
+ if (obj.type === 'array' && obj.items?.type) {
292
+ return obj.items.type === 'SquizLink' || obj.items.type === 'SquizImage';
293
+ }
294
+
295
+ // Recursively check properties
296
+ if (obj.properties) {
297
+ return Object.values(obj.properties).some((prop: any) => checkForPrimitiveArrays(prop));
298
+ }
299
+
300
+ // Check in then/else branches and nested allOf
301
+ if (obj.then && checkForPrimitiveArrays(obj.then)) return true;
302
+ if (obj.else && checkForPrimitiveArrays(obj.else)) return true;
303
+ if (obj.allOf && Array.isArray(obj.allOf)) {
304
+ return obj.allOf.some((item: any) => checkForPrimitiveArrays(item));
305
+ }
306
+
307
+ return false;
308
+ };
309
+
310
+ // Check both inside allOf conditions AND in the main schema properties
311
+ // because primitive arrays in main properties can be affected by allOf processing
312
+ const hasInAllOf = schema.allOf.some((condition: any) => checkForPrimitiveArrays(condition));
313
+ const hasInMainSchema = checkForPrimitiveArrays(schema);
314
+
315
+ return hasInAllOf || hasInMainSchema;
316
+ }
317
+
318
+ /**
319
+ * Check if a specific schema path should be protected from data corruption
320
+ * Only protects arrays of SquizLink/SquizImage types
321
+ */
322
+ private isProtectedPrimitivePath(resolvedSchema: any, _resolvedPointer: string): boolean {
323
+ if (!resolvedSchema) return false;
324
+
325
+ // Check if this is an array of SquizLink/SquizImage
326
+ if (resolvedSchema.type === 'array' && resolvedSchema.items?.type) {
327
+ return resolvedSchema.items.type === 'SquizLink' || resolvedSchema.items.type === 'SquizImage';
328
+ }
329
+
330
+ // Check if this is a direct SquizLink/SquizImage type
331
+ if (resolvedSchema.type === 'SquizLink' || resolvedSchema.type === 'SquizImage') {
332
+ return true;
333
+ }
334
+
335
+ return false;
336
+ }
337
+
338
+ /**
339
+ * Get value at a specific JSON pointer path from the data structure
340
+ * Used to preserve original data during schema traversal
341
+ */
342
+ private getValueAtPointer(data: any, pointer: string): any {
343
+ if (pointer === '#') return data;
344
+
345
+ // Remove the '#/' prefix and split by '/'
346
+ const path = pointer.replace(/^#\//, '').split('/');
347
+ let current = data;
348
+
349
+ for (const segment of path) {
350
+ if (current === null || current === undefined) return undefined;
351
+
352
+ // Handle array indices
353
+ if (Array.isArray(current)) {
354
+ const index = parseInt(segment, 10);
355
+ if (isNaN(index) || index < 0 || index >= current.length) return undefined;
356
+ current = current[index];
357
+ } else if (typeof current === 'object') {
358
+ // Handle object properties
359
+ current = current[segment];
360
+ } else {
361
+ return undefined;
362
+ }
363
+ }
364
+
365
+ return current;
366
+ }
367
+
368
+ /**
369
+ * Check if the value is already a resolved primitive type (SquizLink, SquizImage)
370
+ * and doesn't need further resolution
371
+ */
372
+ private isAlreadyResolvedPrimitive(schema: JSONSchema, value: unknown): boolean {
373
+ if (!value || typeof value !== 'object') return false;
374
+
375
+ // Schema hints: check either title or type when present
376
+ const schemaType = (schema as any)?.type ?? (schema as any)?.title;
377
+
378
+ // Shape-based detection for SquizLink
379
+ const v: any = value;
380
+ const looksLikeSquizLink = typeof v.text === 'string' && typeof v.url === 'string';
381
+ if (looksLikeSquizLink) {
382
+ if (schemaType === 'SquizLink' || schemaType === undefined) return true;
383
+ // Even if schema hint differs (e.g., through allOf), preserve already-formed link objects
384
+ return true;
385
+ }
386
+
387
+ // Shape-based detection for SquizImage
388
+ const looksLikeSquizImage =
389
+ typeof v.name === 'string' && v.imageVariations && typeof v.imageVariations === 'object';
390
+ if (looksLikeSquizImage) {
391
+ if (schemaType === 'SquizImage' || schemaType === undefined) return true;
392
+ return true;
393
+ }
394
+
395
+ return false;
396
+ }
168
397
  }