@scalar/json-magic 0.1.0 → 0.3.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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +4 -3
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +21 -3
  4. package/dist/bundle/bundle.d.ts +84 -14
  5. package/dist/bundle/bundle.d.ts.map +1 -1
  6. package/dist/bundle/bundle.js +60 -15
  7. package/dist/bundle/bundle.js.map +3 -3
  8. package/dist/bundle/index.d.ts +2 -1
  9. package/dist/bundle/index.d.ts.map +1 -1
  10. package/dist/bundle/index.js.map +2 -2
  11. package/dist/bundle/plugins/fetch-urls/index.d.ts +2 -2
  12. package/dist/bundle/plugins/fetch-urls/index.d.ts.map +1 -1
  13. package/dist/bundle/plugins/fetch-urls/index.js +1 -0
  14. package/dist/bundle/plugins/fetch-urls/index.js.map +2 -2
  15. package/dist/bundle/plugins/parse-json/index.d.ts +2 -2
  16. package/dist/bundle/plugins/parse-json/index.d.ts.map +1 -1
  17. package/dist/bundle/plugins/parse-json/index.js +1 -0
  18. package/dist/bundle/plugins/parse-json/index.js.map +2 -2
  19. package/dist/bundle/plugins/parse-yaml/index.d.ts +2 -2
  20. package/dist/bundle/plugins/parse-yaml/index.d.ts.map +1 -1
  21. package/dist/bundle/plugins/parse-yaml/index.js +1 -0
  22. package/dist/bundle/plugins/parse-yaml/index.js.map +2 -2
  23. package/dist/bundle/plugins/read-files/index.d.ts +2 -2
  24. package/dist/bundle/plugins/read-files/index.d.ts.map +1 -1
  25. package/dist/bundle/plugins/read-files/index.js +1 -0
  26. package/dist/bundle/plugins/read-files/index.js.map +2 -2
  27. package/dist/diff/apply.d.ts +1 -1
  28. package/dist/diff/apply.d.ts.map +1 -1
  29. package/dist/diff/apply.js.map +2 -2
  30. package/dist/diff/diff.d.ts +2 -2
  31. package/dist/diff/diff.d.ts.map +1 -1
  32. package/dist/diff/diff.js.map +2 -2
  33. package/dist/diff/merge.d.ts +3 -3
  34. package/dist/diff/merge.d.ts.map +1 -1
  35. package/dist/diff/merge.js.map +2 -2
  36. package/dist/magic-proxy/proxy.d.ts +23 -42
  37. package/dist/magic-proxy/proxy.d.ts.map +1 -1
  38. package/dist/magic-proxy/proxy.js +103 -80
  39. package/dist/magic-proxy/proxy.js.map +3 -3
  40. package/dist/utils/is-object.d.ts +1 -1
  41. package/dist/utils/is-object.d.ts.map +1 -1
  42. package/dist/utils/is-object.js.map +2 -2
  43. package/package.json +11 -10
  44. package/src/bundle/bundle.test.ts +591 -47
  45. package/src/bundle/bundle.ts +173 -32
  46. package/src/bundle/index.ts +2 -1
  47. package/src/bundle/plugins/fetch-urls/index.ts +3 -2
  48. package/src/bundle/plugins/parse-json/index.ts +3 -2
  49. package/src/bundle/plugins/parse-yaml/index.ts +3 -2
  50. package/src/bundle/plugins/read-files/index.ts +4 -2
  51. package/src/dereference/dereference.test.ts +26 -18
  52. package/src/diff/apply.ts +8 -3
  53. package/src/diff/diff.ts +3 -3
  54. package/src/diff/merge.ts +6 -6
  55. package/src/magic-proxy/proxy.test.ts +1095 -100
  56. package/src/magic-proxy/proxy.ts +150 -171
  57. package/src/utils/is-object.ts +1 -1
@@ -77,7 +77,7 @@ export type ResolveResult = { ok: true; data: unknown } | { ok: false }
77
77
  * // No matching plugin returns { ok: false }
78
78
  * await resolveContents('#/components/schemas/User', [urlPlugin, filePlugin])
79
79
  */
80
- async function resolveContents(value: string, plugins: Plugin[]): Promise<ResolveResult> {
80
+ async function resolveContents(value: string, plugins: LoaderPlugin[]): Promise<ResolveResult> {
81
81
  const plugin = plugins.find((p) => p.validate(value))
82
82
 
83
83
  if (plugin) {
@@ -206,6 +206,12 @@ function resolveReferencePath(base: string, relativePath: string) {
206
206
  if (isRemoteUrl(base)) {
207
207
  const url = new URL(base)
208
208
 
209
+ // If the url stars with a / we want it to resolve from the origin so we replace the pathname
210
+ if (relativePath.startsWith('/')) {
211
+ url.pathname = relativePath
212
+ return url.toString()
213
+ }
214
+
209
215
  const mergedPath = path.join(path.dirname(url.pathname), relativePath)
210
216
  return new URL(mergedPath, base).toString()
211
217
  }
@@ -360,22 +366,68 @@ const resolveAndCopyReferences = (
360
366
  }
361
367
 
362
368
  /**
363
- * Represents a plugin that handles resolving references from external sources.
364
- * Plugins are responsible for fetching and processing data from different sources
365
- * like URLs or the filesystem. Each plugin must implement validation to determine
366
- * if it can handle a specific reference, and an execution function to perform
367
- * the actual resolution.
369
+ * A loader plugin for resolving external references during bundling.
370
+ * Loader plugins are responsible for handling specific types of external references,
371
+ * such as files, URLs, or custom protocols. Each loader plugin must provide:
372
+ *
373
+ * - A `validate` function to determine if the plugin can handle a given reference string.
374
+ * - An `exec` function to asynchronously fetch and resolve the referenced data,
375
+ * returning a Promise that resolves to a `ResolveResult`.
368
376
  *
369
- * @property validate - Determines if this plugin can handle the given reference
370
- * @property exec - Fetches and processes the reference, returning the resolved data
377
+ * Loader plugins enable extensible support for different reference sources in the bundler.
378
+ *
379
+ * @property type - The plugin type, always 'loader' for loader plugins.
380
+ * @property validate - Function to check if the plugin can handle a given reference value.
381
+ * @property exec - Function to fetch and resolve the reference, returning the resolved data.
371
382
  */
372
- export type Plugin = {
373
- // Determines if this plugin can handle the given reference value
383
+ export type LoaderPlugin = {
384
+ type: 'loader'
385
+ // Returns true if this plugin can handle the given reference value
374
386
  validate: (value: string) => boolean
375
- // Fetches and processes the reference, returning the resolved data
387
+ // Asynchronously fetches and resolves the reference, returning the resolved data
376
388
  exec: (value: string) => Promise<ResolveResult>
377
389
  }
378
390
 
391
+ /**
392
+ * Context information for a node during traversal or processing.
393
+ *
394
+ * Note: The `path` parameter represents the path to the current node being processed.
395
+ * If you are performing a partial bundle (i.e., providing a custom root), this path will be relative
396
+ * to the root you provide, not the absolute root of the original document. You may need to prefix
397
+ * it with your own base path if you want to construct a full path from the absolute document root.
398
+ *
399
+ * - `path`: The JSON pointer path (as an array of strings) from the document root to the current node.
400
+ * - `resolutionCache`: A cache for storing promises of resolved references.
401
+ */
402
+ type NodeProcessContext = {
403
+ path: readonly string[]
404
+ resolutionCache: Map<string, Promise<Readonly<ResolveResult>>>
405
+ parentNode: UnknownObject | null
406
+ rootNode: UnknownObject
407
+ loaders: LoaderPlugin[]
408
+ }
409
+
410
+ /**
411
+ * A plugin type for lifecycle hooks, allowing custom logic to be injected into the bundler's process.
412
+ * This type extends the Config['hooks'] interface and is identified by type: 'lifecycle'.
413
+ */
414
+ export type LifecyclePlugin = { type: 'lifecycle' } & Config['hooks']
415
+
416
+ /**
417
+ * Represents a plugin used by the bundler for extensibility.
418
+ *
419
+ * Plugins can be either:
420
+ * - Loader plugins: Responsible for resolving and loading external references (e.g., from files, URLs, or custom sources).
421
+ * - Lifecycle plugins: Provide lifecycle hooks to customize or extend the bundling process.
422
+ *
423
+ * Loader plugins must implement:
424
+ * - `validate`: Checks if the plugin can handle a given reference value.
425
+ * - `exec`: Asynchronously resolves and returns the referenced data.
426
+ *
427
+ * Lifecycle plugins extend the bundler's lifecycle hooks for custom logic.
428
+ */
429
+ export type Plugin = LoaderPlugin | LifecyclePlugin
430
+
379
431
  /**
380
432
  * Configuration options for the bundler.
381
433
  * Controls how external references are resolved and processed during bundling.
@@ -402,6 +454,13 @@ type Config = {
402
454
  */
403
455
  depth?: number
404
456
 
457
+ /**
458
+ * Optional origin path for the bundler.
459
+ * Used to resolve relative paths in references, especially when the input is a string URL or file path.
460
+ * If not provided, the bundler will use the input value as the origin.
461
+ */
462
+ origin?: string
463
+
405
464
  /**
406
465
  * Optional cache to store promises of resolved references.
407
466
  * Helps avoid duplicate fetches/reads of the same resource by storing
@@ -441,12 +500,31 @@ type Config = {
441
500
  * Allows tracking the progress and status of reference resolution.
442
501
  */
443
502
  hooks?: Partial<{
444
- /** Called when starting to resolve a reference */
445
- onResolveStart: (node: Record<string, unknown> & Record<'$ref', unknown>) => void
446
- /** Called when a reference resolution fails */
447
- onResolveError: (node: Record<string, unknown> & Record<'$ref', unknown>) => void
448
- /** Called when a reference is successfully resolved */
449
- onResolveSuccess: (node: Record<string, unknown> & Record<'$ref', unknown>) => void
503
+ /**
504
+ * Optional hook called when the bundler starts resolving a $ref.
505
+ * Useful for tracking or logging the beginning of a reference resolution.
506
+ */
507
+ onResolveStart: (node: UnknownObject & Record<'$ref', unknown>) => void
508
+ /**
509
+ * Optional hook called when the bundler fails to resolve a $ref.
510
+ * Can be used for error handling, logging, or custom error reporting.
511
+ */
512
+ onResolveError: (node: UnknownObject & Record<'$ref', unknown>) => void
513
+ /**
514
+ * Optional hook called when the bundler successfully resolves a $ref.
515
+ * Useful for tracking successful resolutions or custom post-processing.
516
+ */
517
+ onResolveSuccess: (node: UnknownObject & Record<'$ref', unknown>) => void
518
+ /**
519
+ * Optional hook invoked before processing a node.
520
+ * Can be used for preprocessing, mutation, or custom logic before the node is handled by the bundler.
521
+ */
522
+ onBeforeNodeProcess: (node: UnknownObject, context: NodeProcessContext) => void | Promise<void>
523
+ /**
524
+ * Optional hook invoked after processing a node.
525
+ * Useful for postprocessing, cleanup, or custom logic after the node has been handled by the bundler.
526
+ */
527
+ onAfterNodeProcess: (node: UnknownObject, context: NodeProcessContext) => void | Promise<void>
450
528
  }>
451
529
  }
452
530
 
@@ -541,6 +619,9 @@ export async function bundle(input: UnknownObject | string, config: Config) {
541
619
  // to avoid duplicate fetches/reads of the same resource
542
620
  const cache = config.cache ?? new Map<string, Promise<ResolveResult>>()
543
621
 
622
+ const loaderPlugins = config.plugins.filter((it) => it.type === 'loader')
623
+ const lifecyclePlugin = config.plugins.filter((it) => it.type === 'lifecycle')
624
+
544
625
  /**
545
626
  * Resolves the input value by either returning it directly if it's not a string,
546
627
  * or attempting to resolve it using the provided plugins if it is a string.
@@ -550,7 +631,7 @@ export async function bundle(input: UnknownObject | string, config: Config) {
550
631
  if (typeof input !== 'string') {
551
632
  return input
552
633
  }
553
- const result = await resolveContents(input, config.plugins)
634
+ const result = await resolveContents(input, loaderPlugins)
554
635
 
555
636
  if (result.ok && typeof result.data === 'object') {
556
637
  return result.data
@@ -583,6 +664,10 @@ export async function bundle(input: UnknownObject | string, config: Config) {
583
664
  // For string inputs that are URLs or file paths, uses the input as the origin.
584
665
  // For non-string inputs or other string types, returns an empty string.
585
666
  const defaultOrigin = () => {
667
+ if (config.origin) {
668
+ return config.origin
669
+ }
670
+
586
671
  if (typeof input !== 'string') {
587
672
  return ''
588
673
  }
@@ -603,7 +688,14 @@ export async function bundle(input: UnknownObject | string, config: Config) {
603
688
  documentRoot[extensions.externalDocumentsMappings],
604
689
  )
605
690
 
606
- const bundler = async (root: unknown, origin: string = defaultOrigin(), isChunkParent = false, depth = 0) => {
691
+ const bundler = async (
692
+ root: unknown,
693
+ origin: string = defaultOrigin(),
694
+ isChunkParent = false,
695
+ depth = 0,
696
+ path: readonly string[] = [],
697
+ parent: UnknownObject = null,
698
+ ) => {
607
699
  // If a maximum depth is set in the config, stop bundling when the current depth reaches or exceeds it
608
700
  if (config.depth !== undefined && depth > config.depth) {
609
701
  return
@@ -621,22 +713,39 @@ export async function bundle(input: UnknownObject | string, config: Config) {
621
713
  // Mark this node as processed before continuing
622
714
  processedNodes.add(root)
623
715
 
716
+ // Invoke the onBeforeNodeProcess hook for the current node before any further processing
717
+ await config.hooks?.onBeforeNodeProcess?.(root as UnknownObject, {
718
+ path,
719
+ resolutionCache: cache,
720
+ parentNode: parent,
721
+ rootNode: documentRoot as UnknownObject,
722
+ loaders: loaderPlugins,
723
+ })
724
+ // Invoke onBeforeNodeProcess hooks from all registered lifecycle plugins
725
+ for (const plugin of lifecyclePlugin) {
726
+ await plugin.onBeforeNodeProcess?.(root as UnknownObject, {
727
+ path,
728
+ resolutionCache: cache,
729
+ parentNode: parent,
730
+ rootNode: documentRoot as UnknownObject,
731
+ loaders: loaderPlugins,
732
+ })
733
+ }
734
+
624
735
  if (typeof root === 'object' && '$ref' in root && typeof root['$ref'] === 'string') {
625
736
  const ref = root['$ref']
626
737
  const isChunk = '$global' in root && typeof root['$global'] === 'boolean' && root['$global']
627
738
 
628
739
  if (isLocalRef(ref)) {
629
740
  if (isPartialBundling) {
741
+ const segments = getSegmentsFromPath(ref.substring(1))
742
+ const parent = segments.length > 0 ? getNestedValue(documentRoot, segments.slice(0, -1)) : undefined
743
+
630
744
  // When doing partial bundling, we need to recursively bundle all dependencies
631
745
  // referenced by this local reference to ensure the partial bundle is complete.
632
746
  // This includes not just the direct reference but also all its dependencies,
633
747
  // creating a complete and self-contained partial bundle.
634
- await bundler(
635
- getNestedValue(documentRoot, getSegmentsFromPath(ref.substring(1))),
636
- origin,
637
- isChunkParent,
638
- depth + 1,
639
- )
748
+ await bundler(getNestedValue(documentRoot, segments), origin, isChunkParent, depth + 1, segments, parent)
640
749
  }
641
750
  return
642
751
  }
@@ -655,10 +764,11 @@ export async function bundle(input: UnknownObject | string, config: Config) {
655
764
  const seen = cache.has(resolvedPath)
656
765
 
657
766
  if (!seen) {
658
- cache.set(resolvedPath, resolveContents(resolvedPath, config.plugins))
767
+ cache.set(resolvedPath, resolveContents(resolvedPath, loaderPlugins))
659
768
  }
660
769
 
661
770
  config?.hooks?.onResolveStart?.(root)
771
+ lifecyclePlugin.forEach((it) => it.onResolveStart?.(root))
662
772
 
663
773
  // Resolve the remote document
664
774
  const result = await cache.get(resolvedPath)
@@ -686,7 +796,11 @@ export async function bundle(input: UnknownObject | string, config: Config) {
686
796
  // to handle any nested references it may contain. We pass the resolvedPath as the new origin
687
797
  // to ensure any relative references within this content are resolved correctly relative to
688
798
  // their new location in the bundled document.
689
- await bundler(result.data, isChunk ? origin : resolvedPath, isChunk, depth + 1)
799
+ await bundler(result.data, isChunk ? origin : resolvedPath, isChunk, depth + 1, [
800
+ extensions.externalDocuments,
801
+ compressedPath,
802
+ documentRoot[extensions.externalDocumentsMappings],
803
+ ])
690
804
 
691
805
  // Store the mapping between hashed keys and original URLs in x-ext-urls
692
806
  // This allows tracking which external URLs were bundled and their corresponding locations
@@ -721,28 +835,55 @@ export async function bundle(input: UnknownObject | string, config: Config) {
721
835
  // This is necessary because we need to maintain the correct path context
722
836
  // for the embedded document while preserving its internal structure
723
837
  root.$ref = prefixInternalRef(`#${path}`, [extensions.externalDocuments, compressedPath])
838
+
724
839
  config?.hooks?.onResolveSuccess?.(root)
840
+ lifecyclePlugin.forEach((it) => it.onResolveSuccess?.(root))
841
+
725
842
  return
726
843
  }
727
844
 
728
845
  config?.hooks?.onResolveError?.(root)
846
+ lifecyclePlugin.forEach((it) => it.onResolveError?.(root))
847
+
729
848
  return console.warn(
730
849
  `Failed to resolve external reference "${resolvedPath}". The reference may be invalid, inaccessible, or missing a loader for this type of reference.`,
731
850
  )
732
851
  }
733
852
 
734
- // Recursively process all child objects to handle nested references
735
- // This ensures we catch and resolve any $refs that exist deeper in the object tree
736
- // We skip EXTERNAL_KEY to avoid processing already bundled content
853
+ // Recursively traverse all child properties of the current object to resolve nested $ref references.
854
+ // This step ensures that any $refs located deeper within the object hierarchy are discovered and processed.
855
+ // We explicitly skip the extension keys (x-ext and x-ext-urls) to avoid reprocessing already bundled or mapped content.
737
856
  await Promise.all(
738
857
  Object.entries(root).map(async ([key, value]) => {
739
- if (key === extensions.externalDocuments) {
858
+ if (key === extensions.externalDocuments || key === extensions.externalDocumentsMappings) {
740
859
  return
741
860
  }
742
861
 
743
- await bundler(value, origin, isChunkParent, depth + 1)
862
+ await bundler(value, origin, isChunkParent, depth + 1, [...path, key], root as UnknownObject)
744
863
  }),
745
864
  )
865
+
866
+ // Invoke the optional onAfterNodeProcess hook from the config, if provided.
867
+ // This allows for custom post-processing logic after a node has been handled by the bundler.
868
+ await config.hooks?.onAfterNodeProcess?.(root as UnknownObject, {
869
+ path,
870
+ resolutionCache: cache,
871
+ parentNode: parent,
872
+ rootNode: documentRoot as UnknownObject,
873
+ loaders: loaderPlugins,
874
+ })
875
+
876
+ // Iterate through all lifecycle plugins and invoke their onAfterNodeProcess hooks, if defined.
877
+ // This enables plugins to perform additional post-processing or cleanup after the node is processed.
878
+ for (const plugin of lifecyclePlugin) {
879
+ await plugin.onAfterNodeProcess?.(root as UnknownObject, {
880
+ path,
881
+ resolutionCache: cache,
882
+ parentNode: parent,
883
+ rootNode: documentRoot as UnknownObject,
884
+ loaders: loaderPlugins,
885
+ })
886
+ }
746
887
  }
747
888
 
748
889
  await bundler(rawSpecification)
@@ -1,2 +1,3 @@
1
1
  // biome-ignore lint/performance/noBarrelFile: <explanation>
2
- export { bundle, type Plugin, type ResolveResult } from './bundle'
2
+ export { bundle } from './bundle'
3
+ export type { Plugin, LoaderPlugin, LifecyclePlugin, ResolveResult } from './bundle'
@@ -1,6 +1,6 @@
1
1
  import { normalize } from '@/utils/normalize'
2
2
  import { createLimiter } from '@/bundle/create-limiter'
3
- import type { Plugin, ResolveResult } from '@/bundle'
3
+ import type { LoaderPlugin, ResolveResult } from '@/bundle'
4
4
  import { isRemoteUrl } from '@/bundle/bundle'
5
5
 
6
6
  type FetchConfig = Partial<{
@@ -83,11 +83,12 @@ export async function fetchUrl(
83
83
  * const result = await urlPlugin.exec('https://example.com/schema.json')
84
84
  * }
85
85
  */
86
- export function fetchUrls(config?: FetchConfig & Partial<{ limit: number | null }>): Plugin {
86
+ export function fetchUrls(config?: FetchConfig & Partial<{ limit: number | null }>): LoaderPlugin {
87
87
  // If there is a limit specified we limit the number of concurrent calls
88
88
  const limiter = config?.limit ? createLimiter(config.limit) : <T>(fn: () => Promise<T>) => fn()
89
89
 
90
90
  return {
91
+ type: 'loader',
91
92
  validate: isRemoteUrl,
92
93
  exec: (value) => fetchUrl(value, limiter, config),
93
94
  }
@@ -1,5 +1,5 @@
1
1
  import { isJsonObject } from '@/utils/is-json-object'
2
- import type { Plugin, ResolveResult } from '@/bundle'
2
+ import type { LoaderPlugin, ResolveResult } from '@/bundle'
3
3
 
4
4
  /**
5
5
  * Creates a plugin that parses JSON strings into JavaScript objects.
@@ -11,8 +11,9 @@ import type { Plugin, ResolveResult } from '@/bundle'
11
11
  * // result = { name: 'John', age: 30 }
12
12
  * ```
13
13
  */
14
- export function parseJson(): Plugin {
14
+ export function parseJson(): LoaderPlugin {
15
15
  return {
16
+ type: 'loader',
16
17
  validate: isJsonObject,
17
18
  exec: async (value): Promise<ResolveResult> => {
18
19
  try {
@@ -1,4 +1,4 @@
1
- import type { Plugin, ResolveResult } from '@/bundle/bundle'
1
+ import type { LoaderPlugin, ResolveResult } from '@/bundle'
2
2
  import { isYaml } from '@/utils/is-yaml'
3
3
  import YAML from 'yaml'
4
4
 
@@ -12,8 +12,9 @@ import YAML from 'yaml'
12
12
  * // result = { name: 'John', age: 30 }
13
13
  * ```
14
14
  */
15
- export function parseYaml(): Plugin {
15
+ export function parseYaml(): LoaderPlugin {
16
16
  return {
17
+ type: 'loader',
17
18
  validate: isYaml,
18
19
  exec: async (value): Promise<ResolveResult> => {
19
20
  try {
@@ -1,5 +1,6 @@
1
1
  import { normalize } from '@/utils/normalize'
2
- import { isFilePath, type Plugin, type ResolveResult } from '@/bundle/bundle'
2
+ import type { LoaderPlugin, ResolveResult } from '@/bundle'
3
+ import { isFilePath } from '@/bundle/bundle'
3
4
 
4
5
  /**
5
6
  * Reads and normalizes data from a local file
@@ -47,8 +48,9 @@ export async function readFile(path: string): Promise<ResolveResult> {
47
48
  * const result = await filePlugin.exec('./local-schema.json')
48
49
  * }
49
50
  */
50
- export function readFiles(): Plugin {
51
+ export function readFiles(): LoaderPlugin {
51
52
  return {
53
+ type: 'loader',
52
54
  validate: isFilePath,
53
55
  exec: readFile,
54
56
  }
@@ -37,18 +37,22 @@ describe('dereference', () => {
37
37
  },
38
38
  },
39
39
  profile: {
40
- 'x-original-ref': '#/users',
41
- name: 'John Doe',
42
- age: 30,
43
- address: {
44
- city: 'New York',
45
- street: '5th Avenue',
40
+ '$ref': '#/users',
41
+ '$ref-value': {
42
+ name: 'John Doe',
43
+ age: 30,
44
+ address: {
45
+ city: 'New York',
46
+ street: '5th Avenue',
47
+ },
46
48
  },
47
49
  },
48
50
  address: {
49
- 'x-original-ref': '#/users/address',
50
- city: 'New York',
51
- street: '5th Avenue',
51
+ '$ref': '#/users/address',
52
+ '$ref-value': {
53
+ city: 'New York',
54
+ street: '5th Avenue',
55
+ },
52
56
  },
53
57
  },
54
58
  })
@@ -97,18 +101,22 @@ describe('dereference', () => {
97
101
  success: true,
98
102
  data: {
99
103
  profile: {
100
- 'x-original-ref': '#/x-ext/5bd1cdd',
101
- name: 'Jane Doe',
102
- age: 25,
103
- address: {
104
- city: 'Los Angeles',
105
- street: 'Sunset Boulevard',
104
+ '$ref': '#/x-ext/5bd1cdd',
105
+ '$ref-value': {
106
+ name: 'Jane Doe',
107
+ age: 25,
108
+ address: {
109
+ city: 'Los Angeles',
110
+ street: 'Sunset Boulevard',
111
+ },
106
112
  },
107
113
  },
108
114
  address: {
109
- 'x-original-ref': '#/x-ext/5bd1cdd/address',
110
- city: 'Los Angeles',
111
- street: 'Sunset Boulevard',
115
+ '$ref': '#/x-ext/5bd1cdd/address',
116
+ '$ref-value': {
117
+ city: 'Los Angeles',
118
+ street: 'Sunset Boulevard',
119
+ },
112
120
  },
113
121
  'x-ext': {
114
122
  '5bd1cdd': userProfile,
package/src/diff/apply.ts CHANGED
@@ -36,9 +36,12 @@ export class InvalidChangesDetectedError extends Error {
36
36
  * const updated = apply(original, changes)
37
37
  * // Result: original document with content added to the 200 response
38
38
  */
39
- export const apply = (document: Record<string, unknown>, diff: Difference[]): Record<string, unknown> => {
39
+ export const apply = <T extends Record<string, unknown>>(
40
+ document: Record<string, unknown>,
41
+ diff: Difference<T>[],
42
+ ): T => {
40
43
  // Traverse the object and apply the change
41
- const applyChange = (current: any, path: string[], d: Difference, depth = 0) => {
44
+ const applyChange = (current: any, path: string[], d: Difference<T>, depth = 0) => {
42
45
  if (path[depth] === undefined) {
43
46
  throw new InvalidChangesDetectedError(
44
47
  `Process aborted. Path ${path.join('.')} at depth ${depth} is undefined, check diff object`,
@@ -74,5 +77,7 @@ export const apply = (document: Record<string, unknown>, diff: Difference[]): Re
74
77
  applyChange(document, d.path, d)
75
78
  }
76
79
 
77
- return document
80
+ // It is safe to cast here because this function mutates the input document
81
+ // to match the target type T as described by the diff changeset.
82
+ return document as T
78
83
  }
package/src/diff/diff.ts CHANGED
@@ -12,7 +12,7 @@ type ChangeType = 'add' | 'update' | 'delete'
12
12
  * @property changes - The new value for the property (for add/update) or the old value (for delete)
13
13
  * @property type - The type of change that occurred
14
14
  */
15
- export type Difference = { path: string[]; changes: any; type: ChangeType }
15
+ export type Difference<_T> = { path: string[]; changes: any; type: ChangeType }
16
16
 
17
17
  /**
18
18
  * Get the difference between two objects.
@@ -49,8 +49,8 @@ export type Difference = { path: string[]; changes: any; type: ChangeType }
49
49
  * // { path: ['user', 'settings', 'theme'], changes: 'dark', type: 'update' }
50
50
  * // ]
51
51
  */
52
- export const diff = (doc1: Record<string, unknown>, doc2: Record<string, unknown>): Difference[] => {
53
- const diff: Difference[] = []
52
+ export const diff = <T extends Record<string, unknown>>(doc1: Record<string, unknown>, doc2: T) => {
53
+ const diff: Difference<T>[] = []
54
54
 
55
55
  const bfs = (el1: unknown, el2: unknown, prefix = []) => {
56
56
  // If the types are different, we know that the property has been added, deleted or updated
package/src/diff/merge.ts CHANGED
@@ -39,7 +39,7 @@ import { isArrayEqual, isKeyCollisions, mergeObjects } from '@/diff/utils'
39
39
  * // ]
40
40
  * // }
41
41
  */
42
- export const merge = (diff1: Difference[], diff2: Difference[]) => {
42
+ export const merge = <T>(diff1: Difference<T>[], diff2: Difference<T>[]) => {
43
43
  // Here we need to use a trie to optimize searching for a prefix
44
44
  // With the naive approach time complexity of the algorithm would be
45
45
  // O(n * m)
@@ -49,7 +49,7 @@ export const merge = (diff1: Difference[], diff2: Difference[]) => {
49
49
  // Assuming that the maximum depth of the nested objects would be constant lets say 0 <= D <= 100
50
50
  // we try to optimize for that using the tire data structure.
51
51
  // So the new time complexity would be O(n * D) where D is the maximum depth of the nested object
52
- const trie = new Trie<{ index: number; changes: Difference }>()
52
+ const trie = new Trie<{ index: number; changes: Difference<T> }>()
53
53
 
54
54
  // Create the trie
55
55
  for (const [index, diff] of diff1.entries()) {
@@ -62,10 +62,10 @@ export const merge = (diff1: Difference[], diff2: Difference[]) => {
62
62
  // Keep related conflicts together for easy A, B pick conflict resolution
63
63
  // map key is going to be conflicting index of first diff list where the diff will be
64
64
  // a delete operation or an add/update operation with a one to many conflicts
65
- const conflictsMap1 = new Map<number, [Difference[], Difference[]]>()
65
+ const conflictsMap1 = new Map<number, [Difference<T>[], Difference<T>[]]>()
66
66
  // map key will be the index from the second diff list where the diff will be
67
67
  // a delete operation with one to many conflicts
68
- const conflictsMap2 = new Map<number, [Difference[], Difference[]]>()
68
+ const conflictsMap2 = new Map<number, [Difference<T>[], Difference<T>[]]>()
69
69
 
70
70
  for (const [index, diff] of diff2.entries()) {
71
71
  trie.findMatch(diff.path, (value) => {
@@ -123,11 +123,11 @@ export const merge = (diff1: Difference[], diff2: Difference[]) => {
123
123
  })
124
124
  }
125
125
 
126
- const conflicts: [Difference[], Difference[]][] = [...conflictsMap1.values(), ...conflictsMap2.values()]
126
+ const conflicts: [Difference<T>[], Difference<T>[]][] = [...conflictsMap1.values(), ...conflictsMap2.values()]
127
127
 
128
128
  // Filter all changes that should be skipped because of conflicts
129
129
  // or auto conflict resolution
130
- const diffs: Difference[] = [
130
+ const diffs: Difference<T>[] = [
131
131
  ...diff1.filter((_, index) => !skipDiff1.has(index)),
132
132
  ...diff2.filter((_, index) => !skipDiff2.has(index)),
133
133
  ]